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