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