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) {
566 let range_start = first_line_info.byte_offset;
567 let range_end = range_start + first_line_info.byte_len;
568 Some(Fix {
569 range: range_start..range_end,
570 replacement: String::new(), })
572 } else {
573 None
574 };
575
576 warnings.push(LintWarning {
577 rule_name: Some(self.name().to_string()),
578 line: start_line,
579 column: start_col,
580 end_line,
581 end_column: end_col,
582 message: format!("First line in file should be a level {} heading", self.level),
583 severity: Severity::Warning,
584 fix,
585 });
586 }
587 Ok(warnings)
588 }
589
590 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
591 if !self.fix_enabled {
592 return Ok(ctx.content.to_string());
593 }
594
595 if self.should_skip(ctx) {
596 return Ok(ctx.content.to_string());
597 }
598
599 let Some(plan) = self.analyze_for_fix(ctx) else {
600 return Ok(ctx.content.to_string());
601 };
602
603 let lines = ctx.raw_lines();
604
605 let mut result = String::new();
606 let preserve_trailing_newline = ctx.content.ends_with('\n');
607
608 match plan {
609 FixPlan::MoveOrRelevel {
610 front_matter_end_idx,
611 heading_idx,
612 is_setext,
613 current_level,
614 needs_level_fix,
615 } => {
616 let heading_line = ctx.lines[heading_idx].content(ctx.content);
617 let fixed_heading = if needs_level_fix || is_setext {
618 self.fix_heading_level(heading_line, current_level, self.level)
619 } else {
620 heading_line.to_string()
621 };
622
623 for line in lines.iter().take(front_matter_end_idx) {
624 result.push_str(line);
625 result.push('\n');
626 }
627 result.push_str(&fixed_heading);
628 result.push('\n');
629 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
630 if idx == heading_idx {
631 continue;
632 }
633 if is_setext && idx == heading_idx + 1 {
634 continue;
635 }
636 result.push_str(line);
637 result.push('\n');
638 }
639 }
640
641 FixPlan::PromotePlainText {
642 front_matter_end_idx,
643 title_line_idx,
644 title_text,
645 } => {
646 let hashes = "#".repeat(self.level);
647 let new_heading = format!("{hashes} {title_text}");
648
649 for line in lines.iter().take(front_matter_end_idx) {
650 result.push_str(line);
651 result.push('\n');
652 }
653 result.push_str(&new_heading);
654 result.push('\n');
655 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
656 if idx == title_line_idx {
657 continue;
658 }
659 result.push_str(line);
660 result.push('\n');
661 }
662 }
663
664 FixPlan::InsertDerived {
665 front_matter_end_idx,
666 derived_title,
667 } => {
668 let hashes = "#".repeat(self.level);
669 let new_heading = format!("{hashes} {derived_title}");
670
671 for line in lines.iter().take(front_matter_end_idx) {
672 result.push_str(line);
673 result.push('\n');
674 }
675 result.push_str(&new_heading);
676 result.push('\n');
677 result.push('\n');
678 for line in lines.iter().skip(front_matter_end_idx) {
679 result.push_str(line);
680 result.push('\n');
681 }
682 }
683 }
684
685 if !preserve_trailing_newline && result.ends_with('\n') {
686 result.pop();
687 }
688
689 Ok(result)
690 }
691
692 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
694 let only_directives = !ctx.content.is_empty()
699 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
700 let t = l.trim();
701 (t.starts_with("{{#") && t.ends_with("}}"))
703 || (t.starts_with("<!--") && t.ends_with("-->"))
705 });
706
707 ctx.content.is_empty()
708 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
709 || only_directives
710 }
711
712 fn as_any(&self) -> &dyn std::any::Any {
713 self
714 }
715
716 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
717 where
718 Self: Sized,
719 {
720 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
722
723 let use_front_matter = !md041_config.front_matter_title.is_empty();
724
725 Box::new(MD041FirstLineHeading::with_pattern(
726 md041_config.level.as_usize(),
727 use_front_matter,
728 md041_config.front_matter_title_pattern,
729 md041_config.fix,
730 ))
731 }
732
733 fn default_config_section(&self) -> Option<(String, toml::Value)> {
734 Some((
735 "MD041".to_string(),
736 toml::toml! {
737 level = 1
738 front-matter-title = "title"
739 front-matter-title-pattern = ""
740 fix = false
741 }
742 .into(),
743 ))
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::lint_context::LintContext;
751
752 #[test]
753 fn test_first_line_is_heading_correct_level() {
754 let rule = MD041FirstLineHeading::default();
755
756 let content = "# My Document\n\nSome content here.";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760 assert!(
761 result.is_empty(),
762 "Expected no warnings when first line is a level 1 heading"
763 );
764 }
765
766 #[test]
767 fn test_first_line_is_heading_wrong_level() {
768 let rule = MD041FirstLineHeading::default();
769
770 let content = "## My Document\n\nSome content here.";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert_eq!(result.len(), 1);
775 assert_eq!(result[0].line, 1);
776 assert!(result[0].message.contains("level 1 heading"));
777 }
778
779 #[test]
780 fn test_first_line_not_heading() {
781 let rule = MD041FirstLineHeading::default();
782
783 let content = "This is not a heading\n\n# This is a heading";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert_eq!(result.len(), 1);
788 assert_eq!(result[0].line, 1);
789 assert!(result[0].message.contains("level 1 heading"));
790 }
791
792 #[test]
793 fn test_empty_lines_before_heading() {
794 let rule = MD041FirstLineHeading::default();
795
796 let content = "\n\n# My Document\n\nSome content.";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800 assert!(
801 result.is_empty(),
802 "Expected no warnings when empty lines precede a valid heading"
803 );
804
805 let content = "\n\nNot a heading\n\nSome content.";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert_eq!(result.len(), 1);
810 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
812 }
813
814 #[test]
815 fn test_front_matter_with_title() {
816 let rule = MD041FirstLineHeading::new(1, true);
817
818 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821 let result = rule.check(&ctx).unwrap();
822 assert!(
823 result.is_empty(),
824 "Expected no warnings when front matter has title field"
825 );
826 }
827
828 #[test]
829 fn test_front_matter_without_title() {
830 let rule = MD041FirstLineHeading::new(1, true);
831
832 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert_eq!(result.len(), 1);
837 assert_eq!(result[0].line, 6); }
839
840 #[test]
841 fn test_front_matter_disabled() {
842 let rule = MD041FirstLineHeading::new(1, false);
843
844 let content = "---\ntitle: My Document\n---\n\nSome content here.";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848 assert_eq!(result.len(), 1);
849 assert_eq!(result[0].line, 5); }
851
852 #[test]
853 fn test_html_comments_before_heading() {
854 let rule = MD041FirstLineHeading::default();
855
856 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860 assert!(
861 result.is_empty(),
862 "HTML comments should be skipped when checking for first heading"
863 );
864 }
865
866 #[test]
867 fn test_multiline_html_comment_before_heading() {
868 let rule = MD041FirstLineHeading::default();
869
870 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873 let result = rule.check(&ctx).unwrap();
874 assert!(
875 result.is_empty(),
876 "Multi-line HTML comments should be skipped when checking for first heading"
877 );
878 }
879
880 #[test]
881 fn test_html_comment_with_blank_lines_before_heading() {
882 let rule = MD041FirstLineHeading::default();
883
884 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let result = rule.check(&ctx).unwrap();
888 assert!(
889 result.is_empty(),
890 "HTML comments with blank lines should be skipped when checking for first heading"
891 );
892 }
893
894 #[test]
895 fn test_html_comment_before_html_heading() {
896 let rule = MD041FirstLineHeading::default();
897
898 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\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 before HTML headings"
905 );
906 }
907
908 #[test]
909 fn test_document_with_only_html_comments() {
910 let rule = MD041FirstLineHeading::default();
911
912 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
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 "Documents with only HTML comments should not trigger MD041"
919 );
920 }
921
922 #[test]
923 fn test_html_comment_followed_by_non_heading() {
924 let rule = MD041FirstLineHeading::default();
925
926 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930 assert_eq!(
931 result.len(),
932 1,
933 "HTML comment followed by non-heading should still trigger MD041"
934 );
935 assert_eq!(
936 result[0].line, 2,
937 "Warning should be on the first non-comment, non-heading line"
938 );
939 }
940
941 #[test]
942 fn test_multiple_html_comments_before_heading() {
943 let rule = MD041FirstLineHeading::default();
944
945 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949 assert!(
950 result.is_empty(),
951 "Multiple HTML comments should all be skipped before heading"
952 );
953 }
954
955 #[test]
956 fn test_html_comment_with_wrong_level_heading() {
957 let rule = MD041FirstLineHeading::default();
958
959 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let result = rule.check(&ctx).unwrap();
963 assert_eq!(
964 result.len(),
965 1,
966 "HTML comment followed by wrong-level heading should still trigger MD041"
967 );
968 assert!(
969 result[0].message.contains("level 1 heading"),
970 "Should require level 1 heading"
971 );
972 }
973
974 #[test]
975 fn test_html_comment_mixed_with_reference_definitions() {
976 let rule = MD041FirstLineHeading::default();
977
978 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let result = rule.check(&ctx).unwrap();
982 assert!(
983 result.is_empty(),
984 "HTML comments and reference definitions should both be skipped before heading"
985 );
986 }
987
988 #[test]
989 fn test_html_comment_after_front_matter() {
990 let rule = MD041FirstLineHeading::default();
991
992 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(
997 result.is_empty(),
998 "HTML comments after front matter should be skipped before heading"
999 );
1000 }
1001
1002 #[test]
1003 fn test_html_comment_not_at_start_should_not_affect_rule() {
1004 let rule = MD041FirstLineHeading::default();
1005
1006 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let result = rule.check(&ctx).unwrap();
1010 assert!(
1011 result.is_empty(),
1012 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_multiline_html_comment_followed_by_non_heading() {
1018 let rule = MD041FirstLineHeading::default();
1019
1020 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023 let result = rule.check(&ctx).unwrap();
1024 assert_eq!(
1025 result.len(),
1026 1,
1027 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1028 );
1029 assert_eq!(
1030 result[0].line, 5,
1031 "Warning should be on the first non-comment, non-heading line"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_different_heading_levels() {
1037 let rule = MD041FirstLineHeading::new(2, false);
1039
1040 let content = "## Second Level Heading\n\nContent.";
1041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042 let result = rule.check(&ctx).unwrap();
1043 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1044
1045 let content = "# First Level Heading\n\nContent.";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048 let result = rule.check(&ctx).unwrap();
1049 assert_eq!(result.len(), 1);
1050 assert!(result[0].message.contains("level 2 heading"));
1051 }
1052
1053 #[test]
1054 fn test_setext_headings() {
1055 let rule = MD041FirstLineHeading::default();
1056
1057 let content = "My Document\n===========\n\nContent.";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060 let result = rule.check(&ctx).unwrap();
1061 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1062
1063 let content = "My Document\n-----------\n\nContent.";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067 assert_eq!(result.len(), 1);
1068 assert!(result[0].message.contains("level 1 heading"));
1069 }
1070
1071 #[test]
1072 fn test_empty_document() {
1073 let rule = MD041FirstLineHeading::default();
1074
1075 let content = "";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079 assert!(result.is_empty(), "Expected no warnings for empty document");
1080 }
1081
1082 #[test]
1083 fn test_whitespace_only_document() {
1084 let rule = MD041FirstLineHeading::default();
1085
1086 let content = " \n\n \t\n";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089 let result = rule.check(&ctx).unwrap();
1090 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1091 }
1092
1093 #[test]
1094 fn test_front_matter_then_whitespace() {
1095 let rule = MD041FirstLineHeading::default();
1096
1097 let content = "---\ntitle: Test\n---\n\n \n\n";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(
1102 result.is_empty(),
1103 "Expected no warnings when no content after front matter"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_multiple_front_matter_types() {
1109 let rule = MD041FirstLineHeading::new(1, true);
1110
1111 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1114 let result = rule.check(&ctx).unwrap();
1115 assert!(
1116 result.is_empty(),
1117 "Expected no warnings for TOML front matter with title"
1118 );
1119
1120 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124 assert!(
1125 result.is_empty(),
1126 "Expected no warnings for JSON front matter with title"
1127 );
1128
1129 let content = "---\ntitle: My Document\n---\n\nContent.";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let result = rule.check(&ctx).unwrap();
1133 assert!(
1134 result.is_empty(),
1135 "Expected no warnings for YAML front matter with title"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_toml_front_matter_with_heading() {
1141 let rule = MD041FirstLineHeading::default();
1142
1143 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 let result = rule.check(&ctx).unwrap();
1147 assert!(
1148 result.is_empty(),
1149 "Expected no warnings when heading follows TOML front matter"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_toml_front_matter_without_title_no_heading() {
1155 let rule = MD041FirstLineHeading::new(1, true);
1156
1157 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161 assert_eq!(result.len(), 1);
1162 assert_eq!(result[0].line, 6);
1163 }
1164
1165 #[test]
1166 fn test_toml_front_matter_level_2_heading() {
1167 let rule = MD041FirstLineHeading::new(2, true);
1169
1170 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
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 "Issue #427: TOML front matter with title and correct heading level should not warn"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1181 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1183
1184 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 let result = rule.check(&ctx).unwrap();
1187 assert!(
1188 result.is_empty(),
1189 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_json_front_matter_with_heading() {
1195 let rule = MD041FirstLineHeading::default();
1196
1197 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201 assert!(
1202 result.is_empty(),
1203 "Expected no warnings when heading follows JSON front matter"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_malformed_front_matter() {
1209 let rule = MD041FirstLineHeading::new(1, true);
1210
1211 let content = "- --\ntitle: My Document\n- --\n\nContent.";
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 "Expected no warnings for malformed front matter with title"
1218 );
1219 }
1220
1221 #[test]
1222 fn test_front_matter_with_heading() {
1223 let rule = MD041FirstLineHeading::default();
1224
1225 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
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 "Expected no warnings when first line after front matter is correct heading"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_no_fix_suggestion() {
1237 let rule = MD041FirstLineHeading::default();
1238
1239 let content = "Not a heading\n\nContent.";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243 assert_eq!(result.len(), 1);
1244 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1245 }
1246
1247 #[test]
1248 fn test_complex_document_structure() {
1249 let rule = MD041FirstLineHeading::default();
1250
1251 let content =
1253 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert!(
1257 result.is_empty(),
1258 "HTML comments should be skipped, so first heading after comment should be valid"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_heading_with_special_characters() {
1264 let rule = MD041FirstLineHeading::default();
1265
1266 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270 assert!(
1271 result.is_empty(),
1272 "Expected no warnings for heading with inline formatting"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_level_configuration() {
1278 for level in 1..=6 {
1280 let rule = MD041FirstLineHeading::new(level, false);
1281
1282 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1284 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1285 let result = rule.check(&ctx).unwrap();
1286 assert!(
1287 result.is_empty(),
1288 "Expected no warnings for correct level {level} heading"
1289 );
1290
1291 let wrong_level = if level == 1 { 2 } else { 1 };
1293 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1294 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1295 let result = rule.check(&ctx).unwrap();
1296 assert_eq!(result.len(), 1);
1297 assert!(result[0].message.contains(&format!("level {level} heading")));
1298 }
1299 }
1300
1301 #[test]
1302 fn test_issue_152_multiline_html_heading() {
1303 let rule = MD041FirstLineHeading::default();
1304
1305 let content = "<h1>\nSome text\n</h1>";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert!(
1310 result.is_empty(),
1311 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_multiline_html_heading_with_attributes() {
1317 let rule = MD041FirstLineHeading::default();
1318
1319 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323 assert!(
1324 result.is_empty(),
1325 "Multi-line HTML heading with attributes should be recognized"
1326 );
1327 }
1328
1329 #[test]
1330 fn test_multiline_html_heading_wrong_level() {
1331 let rule = MD041FirstLineHeading::default();
1332
1333 let content = "<h2>\nSome text\n</h2>";
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336 let result = rule.check(&ctx).unwrap();
1337 assert_eq!(result.len(), 1);
1338 assert!(result[0].message.contains("level 1 heading"));
1339 }
1340
1341 #[test]
1342 fn test_multiline_html_heading_with_content_after() {
1343 let rule = MD041FirstLineHeading::default();
1344
1345 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348 let result = rule.check(&ctx).unwrap();
1349 assert!(
1350 result.is_empty(),
1351 "Multi-line HTML heading followed by content should be valid"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_multiline_html_heading_incomplete() {
1357 let rule = MD041FirstLineHeading::default();
1358
1359 let content = "<h1>\nSome text\n\nMore content without closing tag";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 let result = rule.check(&ctx).unwrap();
1363 assert_eq!(result.len(), 1);
1364 assert!(result[0].message.contains("level 1 heading"));
1365 }
1366
1367 #[test]
1368 fn test_singleline_html_heading_still_works() {
1369 let rule = MD041FirstLineHeading::default();
1370
1371 let content = "<h1>My Document</h1>\n\nContent.";
1373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374 let result = rule.check(&ctx).unwrap();
1375 assert!(
1376 result.is_empty(),
1377 "Single-line HTML headings should still be recognized"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_multiline_html_heading_with_nested_tags() {
1383 let rule = MD041FirstLineHeading::default();
1384
1385 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 let result = rule.check(&ctx).unwrap();
1389 assert!(
1390 result.is_empty(),
1391 "Multi-line HTML heading with nested tags should be recognized"
1392 );
1393 }
1394
1395 #[test]
1396 fn test_multiline_html_heading_various_levels() {
1397 for level in 1..=6 {
1399 let rule = MD041FirstLineHeading::new(level, false);
1400
1401 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1403 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1404 let result = rule.check(&ctx).unwrap();
1405 assert!(
1406 result.is_empty(),
1407 "Multi-line HTML heading at level {level} should be recognized"
1408 );
1409
1410 let wrong_level = if level == 1 { 2 } else { 1 };
1412 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1413 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1414 let result = rule.check(&ctx).unwrap();
1415 assert_eq!(result.len(), 1);
1416 assert!(result[0].message.contains(&format!("level {level} heading")));
1417 }
1418 }
1419
1420 #[test]
1421 fn test_issue_152_nested_heading_spans_many_lines() {
1422 let rule = MD041FirstLineHeading::default();
1423
1424 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>";
1425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1426 let result = rule.check(&ctx).unwrap();
1427 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1428 }
1429
1430 #[test]
1431 fn test_issue_152_picture_tag_heading() {
1432 let rule = MD041FirstLineHeading::default();
1433
1434 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>";
1435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436 let result = rule.check(&ctx).unwrap();
1437 assert!(
1438 result.is_empty(),
1439 "Picture tag inside multi-line HTML heading should be recognized"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_badge_images_before_heading() {
1445 let rule = MD041FirstLineHeading::default();
1446
1447 let content = "\n\n# My Project";
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450 let result = rule.check(&ctx).unwrap();
1451 assert!(result.is_empty(), "Badge image should be skipped");
1452
1453 let content = " \n\n# My Project";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let result = rule.check(&ctx).unwrap();
1457 assert!(result.is_empty(), "Multiple badges should be skipped");
1458
1459 let content = "[](https://example.com)\n\n# My Project";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert!(result.is_empty(), "Linked badge should be skipped");
1464 }
1465
1466 #[test]
1467 fn test_multiple_badge_lines_before_heading() {
1468 let rule = MD041FirstLineHeading::default();
1469
1470 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let result = rule.check(&ctx).unwrap();
1474 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1475 }
1476
1477 #[test]
1478 fn test_badges_without_heading_still_warns() {
1479 let rule = MD041FirstLineHeading::default();
1480
1481 let content = "\n\nThis is not a heading.";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let result = rule.check(&ctx).unwrap();
1485 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1486 }
1487
1488 #[test]
1489 fn test_mixed_content_not_badge_line() {
1490 let rule = MD041FirstLineHeading::default();
1491
1492 let content = " Some text here\n\n# Heading";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let result = rule.check(&ctx).unwrap();
1496 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1497 }
1498
1499 #[test]
1500 fn test_is_badge_image_line_unit() {
1501 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1503 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1504 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1505 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1506
1507 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1509 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1510 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1511 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1512 }
1513
1514 #[test]
1518 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1519 let rule = MD041FirstLineHeading::default();
1520
1521 let content = "[](){ #example }\n# Title";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1524 let result = rule.check(&ctx).unwrap();
1525 assert!(
1526 result.is_empty(),
1527 "MkDocs anchor line should be skipped in MkDocs flavor"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1533 let rule = MD041FirstLineHeading::default();
1534
1535 let content = "[](){ #example }\n# Title";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538 let result = rule.check(&ctx).unwrap();
1539 assert_eq!(
1540 result.len(),
1541 1,
1542 "MkDocs anchor line should NOT be skipped in Standard flavor"
1543 );
1544 }
1545
1546 #[test]
1547 fn test_multiple_mkdocs_anchors_before_heading() {
1548 let rule = MD041FirstLineHeading::default();
1549
1550 let content = "[](){ #first }\n[](){ #second }\n# Title";
1552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1553 let result = rule.check(&ctx).unwrap();
1554 assert!(
1555 result.is_empty(),
1556 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_mkdocs_anchor_with_front_matter() {
1562 let rule = MD041FirstLineHeading::default();
1563
1564 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1567 let result = rule.check(&ctx).unwrap();
1568 assert!(
1569 result.is_empty(),
1570 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_mkdocs_anchor_kramdown_style() {
1576 let rule = MD041FirstLineHeading::default();
1577
1578 let content = "[](){: #anchor }\n# Title";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1581 let result = rule.check(&ctx).unwrap();
1582 assert!(
1583 result.is_empty(),
1584 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_mkdocs_anchor_without_heading_still_warns() {
1590 let rule = MD041FirstLineHeading::default();
1591
1592 let content = "[](){ #anchor }\nThis is not a heading.";
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1595 let result = rule.check(&ctx).unwrap();
1596 assert_eq!(
1597 result.len(),
1598 1,
1599 "MkDocs anchor followed by non-heading should still trigger MD041"
1600 );
1601 }
1602
1603 #[test]
1604 fn test_mkdocs_anchor_with_html_comment() {
1605 let rule = MD041FirstLineHeading::default();
1606
1607 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1610 let result = rule.check(&ctx).unwrap();
1611 assert!(
1612 result.is_empty(),
1613 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1614 );
1615 }
1616
1617 #[test]
1620 fn test_fix_disabled_by_default() {
1621 use crate::rule::Rule;
1622 let rule = MD041FirstLineHeading::default();
1623
1624 let content = "## Wrong Level\n\nContent.";
1626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1627 let fixed = rule.fix(&ctx).unwrap();
1628 assert_eq!(fixed, content, "Fix should not change content when disabled");
1629 }
1630
1631 #[test]
1632 fn test_fix_wrong_heading_level() {
1633 use crate::rule::Rule;
1634 let rule = MD041FirstLineHeading {
1635 level: 1,
1636 front_matter_title: false,
1637 front_matter_title_pattern: None,
1638 fix_enabled: true,
1639 };
1640
1641 let content = "## Wrong Level\n\nContent.\n";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644 let fixed = rule.fix(&ctx).unwrap();
1645 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1646 }
1647
1648 #[test]
1649 fn test_fix_heading_after_preamble() {
1650 use crate::rule::Rule;
1651 let rule = MD041FirstLineHeading {
1652 level: 1,
1653 front_matter_title: false,
1654 front_matter_title_pattern: None,
1655 fix_enabled: true,
1656 };
1657
1658 let content = "\n\n# Title\n\nContent.\n";
1660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661 let fixed = rule.fix(&ctx).unwrap();
1662 assert!(
1663 fixed.starts_with("# Title\n"),
1664 "Heading should be moved to first line, got: {fixed}"
1665 );
1666 }
1667
1668 #[test]
1669 fn test_fix_heading_after_html_comment() {
1670 use crate::rule::Rule;
1671 let rule = MD041FirstLineHeading {
1672 level: 1,
1673 front_matter_title: false,
1674 front_matter_title_pattern: None,
1675 fix_enabled: true,
1676 };
1677
1678 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 let fixed = rule.fix(&ctx).unwrap();
1682 assert!(
1683 fixed.starts_with("# Title\n"),
1684 "Heading should be moved above comment, got: {fixed}"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_fix_heading_level_and_move() {
1690 use crate::rule::Rule;
1691 let rule = MD041FirstLineHeading {
1692 level: 1,
1693 front_matter_title: false,
1694 front_matter_title_pattern: None,
1695 fix_enabled: true,
1696 };
1697
1698 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701 let fixed = rule.fix(&ctx).unwrap();
1702 assert!(
1703 fixed.starts_with("# Wrong Level\n"),
1704 "Heading should be fixed and moved, got: {fixed}"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_fix_with_front_matter() {
1710 use crate::rule::Rule;
1711 let rule = MD041FirstLineHeading {
1712 level: 1,
1713 front_matter_title: false,
1714 front_matter_title_pattern: None,
1715 fix_enabled: true,
1716 };
1717
1718 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721 let fixed = rule.fix(&ctx).unwrap();
1722 assert!(
1723 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1724 "Heading should be right after front matter, got: {fixed}"
1725 );
1726 }
1727
1728 #[test]
1729 fn test_fix_with_toml_front_matter() {
1730 use crate::rule::Rule;
1731 let rule = MD041FirstLineHeading {
1732 level: 1,
1733 front_matter_title: false,
1734 front_matter_title_pattern: None,
1735 fix_enabled: true,
1736 };
1737
1738 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 let fixed = rule.fix(&ctx).unwrap();
1742 assert!(
1743 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1744 "Heading should be right after TOML front matter, got: {fixed}"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_fix_cannot_fix_no_heading() {
1750 use crate::rule::Rule;
1751 let rule = MD041FirstLineHeading {
1752 level: 1,
1753 front_matter_title: false,
1754 front_matter_title_pattern: None,
1755 fix_enabled: true,
1756 };
1757
1758 let content = "Just some text.\n\nMore text.\n";
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761 let fixed = rule.fix(&ctx).unwrap();
1762 assert_eq!(fixed, content, "Should not change content when no heading exists");
1763 }
1764
1765 #[test]
1766 fn test_fix_cannot_fix_content_before_heading() {
1767 use crate::rule::Rule;
1768 let rule = MD041FirstLineHeading {
1769 level: 1,
1770 front_matter_title: false,
1771 front_matter_title_pattern: None,
1772 fix_enabled: true,
1773 };
1774
1775 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let fixed = rule.fix(&ctx).unwrap();
1779 assert_eq!(
1780 fixed, content,
1781 "Should not change content when real content exists before heading"
1782 );
1783 }
1784
1785 #[test]
1786 fn test_fix_already_correct() {
1787 use crate::rule::Rule;
1788 let rule = MD041FirstLineHeading {
1789 level: 1,
1790 front_matter_title: false,
1791 front_matter_title_pattern: None,
1792 fix_enabled: true,
1793 };
1794
1795 let content = "# Title\n\nContent.\n";
1797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1798 let fixed = rule.fix(&ctx).unwrap();
1799 assert_eq!(fixed, content, "Should not change already correct content");
1800 }
1801
1802 #[test]
1803 fn test_fix_setext_heading_removes_underline() {
1804 use crate::rule::Rule;
1805 let rule = MD041FirstLineHeading {
1806 level: 1,
1807 front_matter_title: false,
1808 front_matter_title_pattern: None,
1809 fix_enabled: true,
1810 };
1811
1812 let content = "Wrong Level\n-----------\n\nContent.\n";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815 let fixed = rule.fix(&ctx).unwrap();
1816 assert_eq!(
1817 fixed, "# Wrong Level\n\nContent.\n",
1818 "Setext heading should be converted to ATX and underline removed"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_fix_setext_h1_heading() {
1824 use crate::rule::Rule;
1825 let rule = MD041FirstLineHeading {
1826 level: 1,
1827 front_matter_title: false,
1828 front_matter_title_pattern: None,
1829 fix_enabled: true,
1830 };
1831
1832 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835 let fixed = rule.fix(&ctx).unwrap();
1836 assert_eq!(
1837 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1838 "Setext h1 should be moved and converted to ATX"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_html_heading_not_claimed_fixable() {
1844 use crate::rule::Rule;
1845 let rule = MD041FirstLineHeading {
1846 level: 1,
1847 front_matter_title: false,
1848 front_matter_title_pattern: None,
1849 fix_enabled: true,
1850 };
1851
1852 let content = "<h2>Title</h2>\n\nContent.\n";
1854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855 let warnings = rule.check(&ctx).unwrap();
1856 assert_eq!(warnings.len(), 1);
1857 assert!(
1858 warnings[0].fix.is_none(),
1859 "HTML heading should not be claimed as fixable"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_no_heading_not_claimed_fixable() {
1865 use crate::rule::Rule;
1866 let rule = MD041FirstLineHeading {
1867 level: 1,
1868 front_matter_title: false,
1869 front_matter_title_pattern: None,
1870 fix_enabled: true,
1871 };
1872
1873 let content = "Just some text.\n\nMore text.\n";
1875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1876 let warnings = rule.check(&ctx).unwrap();
1877 assert_eq!(warnings.len(), 1);
1878 assert!(
1879 warnings[0].fix.is_none(),
1880 "Document without heading should not be claimed as fixable"
1881 );
1882 }
1883
1884 #[test]
1885 fn test_content_before_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 = "Intro text.\n\n## Heading\n\nMore.\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 "Document with content before heading should not be claimed as fixable"
1902 );
1903 }
1904
1905 #[test]
1908 fn test_fix_html_block_before_heading_is_now_fixable() {
1909 use crate::rule::Rule;
1910 let rule = MD041FirstLineHeading {
1911 level: 1,
1912 front_matter_title: false,
1913 front_matter_title_pattern: None,
1914 fix_enabled: true,
1915 };
1916
1917 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
1919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1920
1921 let warnings = rule.check(&ctx).unwrap();
1922 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
1923 assert!(
1924 warnings[0].fix.is_some(),
1925 "Should be fixable: heading exists after HTML block preamble"
1926 );
1927
1928 let fixed = rule.fix(&ctx).unwrap();
1929 assert!(
1930 fixed.starts_with("# My Document\n"),
1931 "Heading should be moved to the top, got: {fixed}"
1932 );
1933 }
1934
1935 #[test]
1936 fn test_fix_html_block_wrong_level_before_heading() {
1937 use crate::rule::Rule;
1938 let rule = MD041FirstLineHeading {
1939 level: 1,
1940 front_matter_title: false,
1941 front_matter_title_pattern: None,
1942 fix_enabled: true,
1943 };
1944
1945 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let fixed = rule.fix(&ctx).unwrap();
1948 assert!(
1949 fixed.starts_with("# Wrong Level\n"),
1950 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
1951 );
1952 }
1953
1954 #[test]
1957 fn test_fix_promote_plain_text_title() {
1958 use crate::rule::Rule;
1959 let rule = MD041FirstLineHeading {
1960 level: 1,
1961 front_matter_title: false,
1962 front_matter_title_pattern: None,
1963 fix_enabled: true,
1964 };
1965
1966 let content = "My Project\n\nSome content.\n";
1967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968
1969 let warnings = rule.check(&ctx).unwrap();
1970 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
1971 assert!(
1972 warnings[0].fix.is_some(),
1973 "Should be fixable: first line is a title candidate"
1974 );
1975
1976 let fixed = rule.fix(&ctx).unwrap();
1977 assert_eq!(
1978 fixed, "# My Project\n\nSome content.\n",
1979 "Title line should be promoted to heading"
1980 );
1981 }
1982
1983 #[test]
1984 fn test_fix_promote_plain_text_title_with_front_matter() {
1985 use crate::rule::Rule;
1986 let rule = MD041FirstLineHeading {
1987 level: 1,
1988 front_matter_title: false,
1989 front_matter_title_pattern: None,
1990 fix_enabled: true,
1991 };
1992
1993 let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
1994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1995 let fixed = rule.fix(&ctx).unwrap();
1996 assert!(
1997 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
1998 "Title should be promoted and placed right after front matter, got: {fixed}"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_fix_no_promote_ends_with_period() {
2004 use crate::rule::Rule;
2005 let rule = MD041FirstLineHeading {
2006 level: 1,
2007 front_matter_title: false,
2008 front_matter_title_pattern: None,
2009 fix_enabled: true,
2010 };
2011
2012 let content = "This is a sentence.\n\nContent.\n";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2015 let fixed = rule.fix(&ctx).unwrap();
2016 assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2017
2018 let warnings = rule.check(&ctx).unwrap();
2019 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2020 }
2021
2022 #[test]
2023 fn test_fix_no_promote_ends_with_colon() {
2024 use crate::rule::Rule;
2025 let rule = MD041FirstLineHeading {
2026 level: 1,
2027 front_matter_title: false,
2028 front_matter_title_pattern: None,
2029 fix_enabled: true,
2030 };
2031
2032 let content = "Note:\n\nContent.\n";
2033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034 let fixed = rule.fix(&ctx).unwrap();
2035 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2036 }
2037
2038 #[test]
2039 fn test_fix_no_promote_if_too_long() {
2040 use crate::rule::Rule;
2041 let rule = MD041FirstLineHeading {
2042 level: 1,
2043 front_matter_title: false,
2044 front_matter_title_pattern: None,
2045 fix_enabled: true,
2046 };
2047
2048 let long_line = "A".repeat(81);
2050 let content = format!("{long_line}\n\nContent.\n");
2051 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2052 let fixed = rule.fix(&ctx).unwrap();
2053 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2054 }
2055
2056 #[test]
2057 fn test_fix_no_promote_if_no_blank_after() {
2058 use crate::rule::Rule;
2059 let rule = MD041FirstLineHeading {
2060 level: 1,
2061 front_matter_title: false,
2062 front_matter_title_pattern: None,
2063 fix_enabled: true,
2064 };
2065
2066 let content = "My Project\nImmediately continues.\n\nContent.\n";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069 let fixed = rule.fix(&ctx).unwrap();
2070 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2071 }
2072
2073 #[test]
2074 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2075 use crate::rule::Rule;
2076 let rule = MD041FirstLineHeading {
2077 level: 1,
2078 front_matter_title: false,
2079 front_matter_title_pattern: None,
2080 fix_enabled: true,
2081 };
2082
2083 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let fixed = rule.fix(&ctx).unwrap();
2088 assert_eq!(
2089 fixed, content,
2090 "Should not fix when title candidate exists before a heading"
2091 );
2092
2093 let warnings = rule.check(&ctx).unwrap();
2094 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2095 }
2096
2097 #[test]
2098 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2099 use crate::rule::Rule;
2100 let rule = MD041FirstLineHeading {
2101 level: 1,
2102 front_matter_title: false,
2103 front_matter_title_pattern: None,
2104 fix_enabled: true,
2105 };
2106
2107 let content = "My Project";
2109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2110 let fixed = rule.fix(&ctx).unwrap();
2111 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2112 }
2113
2114 #[test]
2117 fn test_fix_insert_derived_directive_only_document() {
2118 use crate::rule::Rule;
2119 use std::path::PathBuf;
2120 let rule = MD041FirstLineHeading {
2121 level: 1,
2122 front_matter_title: false,
2123 front_matter_title_pattern: None,
2124 fix_enabled: true,
2125 };
2126
2127 let content = "!!! note\n This is a note.\n";
2130 let ctx = LintContext::new(
2131 content,
2132 crate::config::MarkdownFlavor::MkDocs,
2133 Some(PathBuf::from("setup-guide.md")),
2134 );
2135
2136 let can_fix = rule.can_fix(&ctx);
2137 assert!(can_fix, "Directive-only document with source file should be fixable");
2138
2139 let fixed = rule.fix(&ctx).unwrap();
2140 assert!(
2141 fixed.starts_with("# Setup Guide\n"),
2142 "Should insert derived heading, got: {fixed}"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_fix_no_insert_derived_without_source_file() {
2148 use crate::rule::Rule;
2149 let rule = MD041FirstLineHeading {
2150 level: 1,
2151 front_matter_title: false,
2152 front_matter_title_pattern: None,
2153 fix_enabled: true,
2154 };
2155
2156 let content = "!!! note\n This is a note.\n";
2158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2159 let fixed = rule.fix(&ctx).unwrap();
2160 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2161 }
2162
2163 #[test]
2164 fn test_fix_no_insert_derived_when_has_real_content() {
2165 use crate::rule::Rule;
2166 use std::path::PathBuf;
2167 let rule = MD041FirstLineHeading {
2168 level: 1,
2169 front_matter_title: false,
2170 front_matter_title_pattern: None,
2171 fix_enabled: true,
2172 };
2173
2174 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2176 let ctx = LintContext::new(
2177 content,
2178 crate::config::MarkdownFlavor::MkDocs,
2179 Some(PathBuf::from("guide.md")),
2180 );
2181 let fixed = rule.fix(&ctx).unwrap();
2182 assert_eq!(
2183 fixed, content,
2184 "Should not insert derived heading when real content is present"
2185 );
2186 }
2187
2188 #[test]
2189 fn test_derive_title_converts_kebab_case() {
2190 use std::path::PathBuf;
2191 let ctx = LintContext::new(
2192 "",
2193 crate::config::MarkdownFlavor::Standard,
2194 Some(PathBuf::from("my-setup-guide.md")),
2195 );
2196 let title = MD041FirstLineHeading::derive_title(&ctx);
2197 assert_eq!(title, Some("My Setup Guide".to_string()));
2198 }
2199
2200 #[test]
2201 fn test_derive_title_converts_underscores() {
2202 use std::path::PathBuf;
2203 let ctx = LintContext::new(
2204 "",
2205 crate::config::MarkdownFlavor::Standard,
2206 Some(PathBuf::from("api_reference.md")),
2207 );
2208 let title = MD041FirstLineHeading::derive_title(&ctx);
2209 assert_eq!(title, Some("Api Reference".to_string()));
2210 }
2211
2212 #[test]
2213 fn test_derive_title_none_without_source_file() {
2214 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2215 let title = MD041FirstLineHeading::derive_title(&ctx);
2216 assert_eq!(title, None);
2217 }
2218
2219 #[test]
2220 fn test_derive_title_index_file_uses_parent_dir() {
2221 use std::path::PathBuf;
2222 let ctx = LintContext::new(
2223 "",
2224 crate::config::MarkdownFlavor::Standard,
2225 Some(PathBuf::from("docs/getting-started/index.md")),
2226 );
2227 let title = MD041FirstLineHeading::derive_title(&ctx);
2228 assert_eq!(title, Some("Getting Started".to_string()));
2229 }
2230
2231 #[test]
2232 fn test_derive_title_readme_file_uses_parent_dir() {
2233 use std::path::PathBuf;
2234 let ctx = LintContext::new(
2235 "",
2236 crate::config::MarkdownFlavor::Standard,
2237 Some(PathBuf::from("my-project/README.md")),
2238 );
2239 let title = MD041FirstLineHeading::derive_title(&ctx);
2240 assert_eq!(title, Some("My Project".to_string()));
2241 }
2242
2243 #[test]
2244 fn test_derive_title_index_without_parent_returns_none() {
2245 use std::path::PathBuf;
2246 let ctx = LintContext::new(
2248 "",
2249 crate::config::MarkdownFlavor::Standard,
2250 Some(PathBuf::from("index.md")),
2251 );
2252 let title = MD041FirstLineHeading::derive_title(&ctx);
2253 assert_eq!(title, None);
2254 }
2255
2256 #[test]
2257 fn test_derive_title_readme_without_parent_returns_none() {
2258 use std::path::PathBuf;
2259 let ctx = LintContext::new(
2260 "",
2261 crate::config::MarkdownFlavor::Standard,
2262 Some(PathBuf::from("README.md")),
2263 );
2264 let title = MD041FirstLineHeading::derive_title(&ctx);
2265 assert_eq!(title, None);
2266 }
2267
2268 #[test]
2269 fn test_derive_title_readme_case_insensitive() {
2270 use std::path::PathBuf;
2271 let ctx = LintContext::new(
2273 "",
2274 crate::config::MarkdownFlavor::Standard,
2275 Some(PathBuf::from("docs/api/readme.md")),
2276 );
2277 let title = MD041FirstLineHeading::derive_title(&ctx);
2278 assert_eq!(title, Some("Api".to_string()));
2279 }
2280
2281 #[test]
2282 fn test_is_title_candidate_basic() {
2283 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2284 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2285 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2286 }
2287
2288 #[test]
2289 fn test_is_title_candidate_rejects_sentence_punctuation() {
2290 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2291 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2292 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2293 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2294 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2295 }
2296
2297 #[test]
2298 fn test_is_title_candidate_rejects_when_no_blank_after() {
2299 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2300 }
2301
2302 #[test]
2303 fn test_is_title_candidate_rejects_long_lines() {
2304 let long = "A".repeat(81);
2305 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2306 let ok = "A".repeat(80);
2308 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2309 }
2310
2311 #[test]
2312 fn test_is_title_candidate_rejects_structural_markdown() {
2313 assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2314 assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2315 assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2316 assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2317 }
2318}