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