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