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