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