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 Self::with_pattern_from(level, front_matter_title, pattern, fix_enabled, false)
84 }
85
86 fn with_pattern_from(
89 level: usize,
90 front_matter_title: bool,
91 pattern: Option<String>,
92 fix_enabled: bool,
93 values_withheld: bool,
94 ) -> Self {
95 let front_matter_title_pattern = pattern.and_then(|p| {
96 crate::rule_config_serde::compile_config_regex(&p, "MD041", "front-matter-title-pattern", values_withheld)
97 });
98
99 Self {
100 level,
101 front_matter_title,
102 front_matter_title_pattern,
103 allow_preamble: false,
104 fix_enabled,
105 }
106 }
107
108 pub fn with_allow_preamble(mut self, allow_preamble: bool) -> Self {
110 self.allow_preamble = allow_preamble;
111 self
112 }
113
114 fn has_front_matter_title(&self, content: &str) -> bool {
115 if !self.front_matter_title {
116 return false;
117 }
118
119 if let Some(ref pattern) = self.front_matter_title_pattern {
121 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
122 for line in front_matter_lines {
123 if pattern.is_match(line) {
124 return true;
125 }
126 }
127 return false;
128 }
129
130 FrontMatterUtils::has_front_matter_field(content, "title:")
132 }
133
134 fn is_non_content_line(line: &str) -> bool {
136 let trimmed = line.trim();
137
138 if trimmed.starts_with('[') && trimmed.contains("]: ") {
140 return true;
141 }
142
143 if trimmed.starts_with('*') && trimmed.contains("]: ") {
145 return true;
146 }
147
148 if Self::is_badge_image_line(trimmed) {
151 return true;
152 }
153
154 false
155 }
156
157 fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
163 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
164
165 let filtered = ctx
166 .filtered_lines()
167 .skip_front_matter()
168 .skip_esm_blocks()
169 .skip_html_comments()
170 .skip_mdx_comments();
171
172 for filtered_line in filtered {
173 let idx = filtered_line.line_num - 1;
174 let line_info = &ctx.lines[idx];
175
176 if line_info.is_blank || line_info.is_kramdown_block_ial {
177 continue;
178 }
179
180 let line_content = filtered_line.content;
181 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
182 continue;
183 }
184 if Self::is_non_content_line(line_content) {
185 continue;
186 }
187 return Some(idx);
188 }
189 None
190 }
191
192 fn first_top_level_heading_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
201 for (idx, line_info) in ctx.lines.iter().enumerate() {
202 if line_info.is_blank
203 || line_info.in_front_matter
204 || line_info.in_code_block
205 || line_info.in_html_comment
206 || line_info.in_mdx_comment
207 || line_info.in_math_block
208 {
209 continue;
210 }
211
212 let in_container = line_info.in_list_block
213 || line_info.blockquote.is_some()
214 || line_info.in_admonition
215 || line_info.in_content_tab
216 || line_info.in_pandoc_div
217 || line_info.in_pymdown_block
218 || line_info.in_kramdown_extension_block;
219 if in_container {
220 continue;
221 }
222
223 if line_info.heading.is_some() {
224 return Some(idx);
225 }
226
227 let continues_html_block = idx > 0 && line_info.in_html_block && ctx.lines[idx - 1].in_html_block;
230 if !continues_html_block && (1..=6).any(|level| Self::is_html_heading(ctx, idx, level)) {
231 return Some(idx);
232 }
233 }
234 None
235 }
236
237 fn checked_line_idx(&self, ctx: &crate::lint_context::LintContext) -> Option<usize> {
244 if self.allow_preamble {
245 Self::first_top_level_heading_idx(ctx)
246 } else {
247 Self::first_content_line_idx(ctx)
248 }
249 }
250
251 fn is_badge_image_line(line: &str) -> bool {
257 if line.is_empty() {
258 return false;
259 }
260
261 if !line.starts_with('!') && !line.starts_with('[') {
263 return false;
264 }
265
266 let mut remaining = line;
268 while !remaining.is_empty() {
269 remaining = remaining.trim_start();
270 if remaining.is_empty() {
271 break;
272 }
273
274 if remaining.starts_with("[![") {
276 if let Some(end) = Self::find_linked_image_end(remaining) {
277 remaining = &remaining[end..];
278 continue;
279 }
280 return false;
281 }
282
283 if remaining.starts_with("![") {
285 if let Some(end) = Self::find_image_end(remaining) {
286 remaining = &remaining[end..];
287 continue;
288 }
289 return false;
290 }
291
292 return false;
294 }
295
296 true
297 }
298
299 fn find_image_end(s: &str) -> Option<usize> {
301 if !s.starts_with("![") {
302 return None;
303 }
304 let alt_end = s[2..].find("](")?;
306 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
309 Some(paren_start + paren_end + 1)
310 }
311
312 fn find_linked_image_end(s: &str) -> Option<usize> {
314 if !s.starts_with("[![") {
315 return None;
316 }
317 let inner_end = Self::find_image_end(&s[1..])?;
319 let after_inner = 1 + inner_end;
320 if !s[after_inner..].starts_with("](") {
322 return None;
323 }
324 let link_start = after_inner + 2;
325 let link_end = s[link_start..].find(')')?;
326 Some(link_start + link_end + 1)
327 }
328
329 fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
331 let trimmed = line.trim_start();
332
333 if trimmed.starts_with('#') {
335 let hashes = "#".repeat(target_level);
336 let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
338 let after_hashes = &trimmed[content_start..];
339 let content = after_hashes.trim_start();
340
341 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
343 format!("{leading_ws}{hashes} {content}")
344 } else {
345 let hashes = "#".repeat(target_level);
348 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
349 format!("{leading_ws}{hashes} {trimmed}")
350 }
351 }
352
353 fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
365 if text.is_empty() {
366 return false;
367 }
368
369 if !next_is_blank_or_eof {
370 return false;
371 }
372
373 if text.chars().count() > 80 {
374 return false;
375 }
376
377 let last_char = text.chars().next_back().unwrap_or(' ');
378 !matches!(last_char, '.' | '?' | '!' | ':' | ';')
379 }
380
381 fn is_promotable_line(line_info: &crate::lint_context::LineInfo) -> bool {
391 line_info.is_paragraph_context() && line_info.list_item.is_none() && line_info.blockquote.is_none()
392 }
393
394 fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
398 let path = ctx.source_file()?;
399 let stem = path.file_stem().and_then(|s| s.to_str())?;
400
401 let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
404 path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
405 } else {
406 stem
407 };
408
409 let title: String = effective_stem
410 .split(['-', '_'])
411 .filter(|w| !w.is_empty())
412 .map(|word| {
413 let mut chars = word.chars();
414 match chars.next() {
415 None => String::new(),
416 Some(first) => {
417 let upper: String = first.to_uppercase().collect();
418 upper + chars.as_str()
419 }
420 }
421 })
422 .collect::<Vec<_>>()
423 .join(" ");
424
425 if title.is_empty() { None } else { Some(title) }
426 }
427
428 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
430 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
432 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
433 && let Some(h_level) = captures.get(1)
434 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
435 {
436 return true;
437 }
438
439 let html_tags = ctx.html_tags();
441 let target_tag = format!("h{level}");
442
443 let opening_index = html_tags.iter().position(|tag| {
445 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
447 && !tag.is_closing
448 });
449
450 let Some(open_idx) = opening_index else {
451 return false;
452 };
453
454 let mut depth = 1usize;
457 for tag in html_tags.iter().skip(open_idx + 1) {
458 if tag.line <= first_line_idx + 1 {
460 continue;
461 }
462
463 if tag.tag_name == target_tag {
464 if tag.is_closing {
465 depth -= 1;
466 if depth == 0 {
467 return true;
468 }
469 } else if !tag.is_self_closing {
470 depth += 1;
471 }
472 }
473 }
474
475 false
476 }
477
478 fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
480 if ctx.lines.is_empty() {
481 return None;
482 }
483
484 if self.allow_preamble {
488 let heading_idx = Self::first_top_level_heading_idx(ctx)?;
489 let heading = ctx.lines[heading_idx].heading.as_ref()?;
490 if heading.level as usize == self.level {
491 return None;
492 }
493 return Some(FixPlan::RelevelInPlace {
494 heading_idx,
495 is_setext: matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
496 current_level: heading.level as usize,
497 });
498 }
499
500 let mut front_matter_end_idx = 0;
502 for line_info in &ctx.lines {
503 if line_info.in_front_matter {
504 front_matter_end_idx += 1;
505 } else {
506 break;
507 }
508 }
509
510 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
511
512 let mut found_heading: Option<(usize, bool, usize)> = None;
514 let mut first_title_candidate: Option<(usize, String)> = None;
516 let mut found_non_title_content = false;
518 let mut saw_non_directive_content = false;
520
521 'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
522 let line_content = line_info.content(ctx.content);
523 let trimmed = line_content.trim();
524
525 let is_preamble = trimmed.is_empty()
527 || line_info.in_html_comment
528 || line_info.in_mdx_comment
529 || line_info.in_html_block
530 || Self::is_non_content_line(line_content)
531 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
532 || line_info.in_kramdown_extension_block
533 || line_info.is_kramdown_block_ial;
534
535 if is_preamble {
536 continue;
537 }
538
539 let is_directive_block = line_info.in_admonition
542 || line_info.in_content_tab
543 || line_info.in_pandoc_div
544 || line_info.is_div_marker
545 || line_info.in_pymdown_block;
546
547 if !is_directive_block {
548 saw_non_directive_content = true;
549 }
550
551 if let Some(heading) = &line_info.heading {
553 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
554 found_heading = Some((idx, is_setext, heading.level as usize));
555 break 'scan;
556 }
557
558 if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
560 let next_is_blank_or_eof = ctx
561 .lines
562 .get(idx + 1)
563 .is_none_or(|l| l.content(ctx.content).trim().is_empty());
564
565 if Self::is_promotable_line(line_info) && Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
566 first_title_candidate = Some((idx, trimmed.to_string()));
567 } else {
568 found_non_title_content = true;
569 }
570 }
571 }
572
573 if let Some((h_idx, is_setext, current_level)) = found_heading {
574 if found_non_title_content || first_title_candidate.is_some() {
578 return None;
579 }
580
581 let needs_level_fix = current_level != self.level;
582 let needs_move = h_idx > front_matter_end_idx;
583
584 if needs_level_fix || needs_move {
585 return Some(FixPlan::MoveOrRelevel {
586 front_matter_end_idx,
587 heading_idx: h_idx,
588 is_setext,
589 current_level,
590 needs_level_fix,
591 });
592 }
593 return None; }
595
596 if let Some((title_idx, title_text)) = first_title_candidate {
599 return Some(FixPlan::PromotePlainText {
600 front_matter_end_idx,
601 title_line_idx: title_idx,
602 title_text,
603 });
604 }
605
606 if !saw_non_directive_content && let Some(derived_title) = Self::derive_title(ctx) {
609 return Some(FixPlan::InsertDerived {
610 front_matter_end_idx,
611 derived_title,
612 });
613 }
614
615 None
616 }
617
618 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
620 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
621 }
622}
623
624impl Rule for MD041FirstLineHeading {
625 fn name(&self) -> &'static str {
626 "MD041"
627 }
628
629 fn description(&self) -> &'static str {
630 "First line in file should be a top level heading"
631 }
632
633 fn fix_capability(&self) -> FixCapability {
638 if self.fix_enabled {
639 FixCapability::ConditionallyFixable
640 } else {
641 FixCapability::Unfixable
642 }
643 }
644
645 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
646 let mut warnings = Vec::new();
647
648 if self.should_skip(ctx) {
650 return Ok(warnings);
651 }
652
653 let Some(first_line_idx) = self.checked_line_idx(ctx) else {
654 return Ok(warnings);
655 };
656
657 let first_line_info = &ctx.lines[first_line_idx];
659 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
660 heading.level as usize == self.level
661 } else {
662 Self::is_html_heading(ctx, first_line_idx, self.level)
664 };
665
666 if !is_correct_heading {
667 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
670 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
671
672 let fix = if self.can_fix(ctx) {
678 self.analyze_for_fix(ctx).and_then(|plan| {
679 let range_start = first_line_info.byte_offset;
680 let range_end = range_start + first_line_info.byte_len;
681 match &plan {
682 FixPlan::MoveOrRelevel {
683 heading_idx,
684 current_level,
685 needs_level_fix,
686 is_setext,
687 ..
688 } if *heading_idx == first_line_idx => {
689 let heading_line = ctx.lines[*heading_idx].content(ctx.content);
691 let replacement = if *needs_level_fix || *is_setext {
692 self.fix_heading_level(heading_line, *current_level, self.level)
693 } else {
694 heading_line.to_string()
695 };
696 Some(Fix::new(range_start..range_end, replacement))
697 }
698 FixPlan::RelevelInPlace {
699 heading_idx,
700 current_level,
701 is_setext,
702 } if *heading_idx == first_line_idx && !*is_setext => {
703 let replacement = self.fix_heading_level(
704 ctx.lines[*heading_idx].content(ctx.content),
705 *current_level,
706 self.level,
707 );
708 Some(Fix::new(range_start..range_end, replacement))
709 }
710 FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
711 let replacement = format!(
712 "{} {}",
713 "#".repeat(self.level),
714 ctx.lines[*title_line_idx].content(ctx.content).trim()
715 );
716 Some(Fix::new(range_start..range_end, replacement))
717 }
718 _ => {
719 self.fix(ctx)
723 .ok()
724 .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
725 }
726 }
727 })
728 } else {
729 None
730 };
731
732 warnings.push(LintWarning {
733 rule_name: Some(self.name().to_string()),
734 line: start_line,
735 column: start_col,
736 end_line,
737 end_column: end_col,
738 message: if self.allow_preamble {
739 format!("First heading in file should be a level {} heading", self.level)
740 } else {
741 format!("First line in file should be a level {} heading", self.level)
742 },
743 severity: Severity::Warning,
744 fix,
745 });
746 }
747 Ok(warnings)
748 }
749
750 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
751 if !self.fix_enabled {
752 return Ok(ctx.content.to_string());
753 }
754
755 if self.should_skip(ctx) {
756 return Ok(ctx.content.to_string());
757 }
758
759 let checked_line = self.checked_line_idx(ctx).map_or(1, |i| i + 1);
762 if ctx.inline_config().is_rule_disabled(self.name(), checked_line) {
763 return Ok(ctx.content.to_string());
764 }
765
766 let Some(plan) = self.analyze_for_fix(ctx) else {
767 return Ok(ctx.content.to_string());
768 };
769
770 let lines = ctx.raw_lines();
771
772 let mut result = String::new();
773 let preserve_trailing_newline = ctx.content.ends_with('\n');
774
775 match plan {
776 FixPlan::MoveOrRelevel {
777 front_matter_end_idx,
778 heading_idx,
779 is_setext,
780 current_level,
781 needs_level_fix,
782 } => {
783 let heading_line = ctx.lines[heading_idx].content(ctx.content);
784 let fixed_heading = if needs_level_fix || is_setext {
785 self.fix_heading_level(heading_line, current_level, self.level)
786 } else {
787 heading_line.to_string()
788 };
789
790 for line in lines.iter().take(front_matter_end_idx) {
791 result.push_str(line);
792 result.push('\n');
793 }
794 result.push_str(&fixed_heading);
795 result.push('\n');
796 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
797 if idx == heading_idx {
798 continue;
799 }
800 if is_setext && idx == heading_idx + 1 {
801 continue;
802 }
803 result.push_str(line);
804 result.push('\n');
805 }
806 }
807
808 FixPlan::PromotePlainText {
809 front_matter_end_idx,
810 title_line_idx,
811 title_text,
812 } => {
813 let hashes = "#".repeat(self.level);
814 let new_heading = format!("{hashes} {title_text}");
815
816 for line in lines.iter().take(front_matter_end_idx) {
817 result.push_str(line);
818 result.push('\n');
819 }
820 result.push_str(&new_heading);
821 result.push('\n');
822 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
823 if idx == title_line_idx {
824 continue;
825 }
826 result.push_str(line);
827 result.push('\n');
828 }
829 }
830
831 FixPlan::RelevelInPlace {
832 heading_idx,
833 is_setext,
834 current_level,
835 } => {
836 for (idx, line) in lines.iter().enumerate() {
837 if idx == heading_idx {
838 result.push_str(&self.fix_heading_level(line, current_level, self.level));
839 result.push('\n');
840 continue;
841 }
842 if is_setext && idx == heading_idx + 1 {
844 continue;
845 }
846 result.push_str(line);
847 result.push('\n');
848 }
849 }
850
851 FixPlan::InsertDerived {
852 front_matter_end_idx,
853 derived_title,
854 } => {
855 let hashes = "#".repeat(self.level);
856 let new_heading = format!("{hashes} {derived_title}");
857
858 for line in lines.iter().take(front_matter_end_idx) {
859 result.push_str(line);
860 result.push('\n');
861 }
862 result.push_str(&new_heading);
863 result.push('\n');
864 result.push('\n');
865 for line in lines.iter().skip(front_matter_end_idx) {
866 result.push_str(line);
867 result.push('\n');
868 }
869 }
870 }
871
872 if !preserve_trailing_newline && result.ends_with('\n') {
873 result.pop();
874 }
875
876 Ok(result)
877 }
878
879 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
881 let only_directives = !ctx.content.is_empty()
886 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
887 let t = l.trim();
888 (t.starts_with("{{#") && t.ends_with("}}"))
890 || (t.starts_with("<!--") && t.ends_with("-->"))
892 });
893
894 ctx.content.is_empty()
895 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
896 || only_directives
897 }
898
899 fn as_any(&self) -> &dyn std::any::Any {
900 self
901 }
902
903 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
904 where
905 Self: Sized,
906 {
907 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
909
910 let use_front_matter = !md041_config.front_matter_title.is_empty();
911
912 Box::new(
913 MD041FirstLineHeading::with_pattern_from(
914 md041_config.level.as_usize(),
915 use_front_matter,
916 md041_config.front_matter_title_pattern,
917 md041_config.fix,
918 config.withheld_rule_values.contains("MD041"),
919 )
920 .with_allow_preamble(md041_config.allow_preamble),
921 )
922 }
923
924 crate::impl_rule_config_sections!(MD041Config);
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use crate::lint_context::LintContext;
931
932 #[test]
933 fn test_first_line_is_heading_correct_level() {
934 let rule = MD041FirstLineHeading::default();
935
936 let content = "# My Document\n\nSome content here.";
938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939 let result = rule.check(&ctx).unwrap();
940 assert!(
941 result.is_empty(),
942 "Expected no warnings when first line is a level 1 heading"
943 );
944 }
945
946 #[test]
947 fn test_first_line_is_heading_wrong_level() {
948 let rule = MD041FirstLineHeading::default();
949
950 let content = "## My Document\n\nSome content here.";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert_eq!(result.len(), 1);
955 assert_eq!(result[0].line, 1);
956 assert!(result[0].message.contains("level 1 heading"));
957 }
958
959 #[test]
960 fn test_first_line_not_heading() {
961 let rule = MD041FirstLineHeading::default();
962
963 let content = "This is not a heading\n\n# This is a heading";
965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let result = rule.check(&ctx).unwrap();
967 assert_eq!(result.len(), 1);
968 assert_eq!(result[0].line, 1);
969 assert!(result[0].message.contains("level 1 heading"));
970 }
971
972 #[test]
973 fn test_empty_lines_before_heading() {
974 let rule = MD041FirstLineHeading::default();
975
976 let content = "\n\n# My Document\n\nSome content.";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979 let result = rule.check(&ctx).unwrap();
980 assert!(
981 result.is_empty(),
982 "Expected no warnings when empty lines precede a valid heading"
983 );
984
985 let content = "\n\nNot a heading\n\nSome content.";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989 assert_eq!(result.len(), 1);
990 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
992 }
993
994 #[test]
995 fn test_front_matter_with_title() {
996 let rule = MD041FirstLineHeading::new(1, true);
997
998 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.check(&ctx).unwrap();
1002 assert!(
1003 result.is_empty(),
1004 "Expected no warnings when front matter has title field"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_front_matter_without_title() {
1010 let rule = MD041FirstLineHeading::new(1, true);
1011
1012 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert_eq!(result.len(), 1);
1017 assert_eq!(result[0].line, 6); }
1019
1020 #[test]
1021 fn test_front_matter_disabled() {
1022 let rule = MD041FirstLineHeading::new(1, false);
1023
1024 let content = "---\ntitle: My Document\n---\n\nSome content here.";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let result = rule.check(&ctx).unwrap();
1028 assert_eq!(result.len(), 1);
1029 assert_eq!(result[0].line, 5); }
1031
1032 #[test]
1033 fn test_html_comments_before_heading() {
1034 let rule = MD041FirstLineHeading::default();
1035
1036 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let result = rule.check(&ctx).unwrap();
1040 assert!(
1041 result.is_empty(),
1042 "HTML comments should be skipped when checking for first heading"
1043 );
1044 }
1045
1046 #[test]
1047 fn test_multiline_html_comment_before_heading() {
1048 let rule = MD041FirstLineHeading::default();
1049
1050 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(
1055 result.is_empty(),
1056 "Multi-line HTML comments should be skipped when checking for first heading"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_html_comment_with_blank_lines_before_heading() {
1062 let rule = MD041FirstLineHeading::default();
1063
1064 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068 assert!(
1069 result.is_empty(),
1070 "HTML comments with blank lines should be skipped when checking for first heading"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_html_comment_before_html_heading() {
1076 let rule = MD041FirstLineHeading::default();
1077
1078 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let result = rule.check(&ctx).unwrap();
1082 assert!(
1083 result.is_empty(),
1084 "HTML comments should be skipped before HTML headings"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_document_with_only_html_comments() {
1090 let rule = MD041FirstLineHeading::default();
1091
1092 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095 let result = rule.check(&ctx).unwrap();
1096 assert!(
1097 result.is_empty(),
1098 "Documents with only HTML comments should not trigger MD041"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_html_comment_followed_by_non_heading() {
1104 let rule = MD041FirstLineHeading::default();
1105
1106 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert_eq!(
1111 result.len(),
1112 1,
1113 "HTML comment followed by non-heading should still trigger MD041"
1114 );
1115 assert_eq!(
1116 result[0].line, 2,
1117 "Warning should be on the first non-comment, non-heading line"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_multiple_html_comments_before_heading() {
1123 let rule = MD041FirstLineHeading::default();
1124
1125 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let result = rule.check(&ctx).unwrap();
1129 assert!(
1130 result.is_empty(),
1131 "Multiple HTML comments should all be skipped before heading"
1132 );
1133 }
1134
1135 #[test]
1136 fn test_html_comment_with_wrong_level_heading() {
1137 let rule = MD041FirstLineHeading::default();
1138
1139 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert_eq!(
1144 result.len(),
1145 1,
1146 "HTML comment followed by wrong-level heading should still trigger MD041"
1147 );
1148 assert!(
1149 result[0].message.contains("level 1 heading"),
1150 "Should require level 1 heading"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_html_comment_mixed_with_reference_definitions() {
1156 let rule = MD041FirstLineHeading::default();
1157
1158 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let result = rule.check(&ctx).unwrap();
1162 assert!(
1163 result.is_empty(),
1164 "HTML comments and reference definitions should both be skipped before heading"
1165 );
1166 }
1167
1168 #[test]
1169 fn test_html_comment_after_front_matter() {
1170 let rule = MD041FirstLineHeading::default();
1171
1172 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let result = rule.check(&ctx).unwrap();
1176 assert!(
1177 result.is_empty(),
1178 "HTML comments after front matter should be skipped before heading"
1179 );
1180 }
1181
1182 #[test]
1183 fn test_html_comment_not_at_start_should_not_affect_rule() {
1184 let rule = MD041FirstLineHeading::default();
1185
1186 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190 assert!(
1191 result.is_empty(),
1192 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_multiline_html_comment_followed_by_non_heading() {
1198 let rule = MD041FirstLineHeading::default();
1199
1200 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 let result = rule.check(&ctx).unwrap();
1204 assert_eq!(
1205 result.len(),
1206 1,
1207 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1208 );
1209 assert_eq!(
1210 result[0].line, 5,
1211 "Warning should be on the first non-comment, non-heading line"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_different_heading_levels() {
1217 let rule = MD041FirstLineHeading::new(2, false);
1219
1220 let content = "## Second Level Heading\n\nContent.";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let result = rule.check(&ctx).unwrap();
1223 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1224
1225 let content = "# First Level Heading\n\nContent.";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229 assert_eq!(result.len(), 1);
1230 assert!(result[0].message.contains("level 2 heading"));
1231 }
1232
1233 #[test]
1234 fn test_setext_headings() {
1235 let rule = MD041FirstLineHeading::default();
1236
1237 let content = "My Document\n===========\n\nContent.";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1242
1243 let content = "My Document\n-----------\n\nContent.";
1245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1246 let result = rule.check(&ctx).unwrap();
1247 assert_eq!(result.len(), 1);
1248 assert!(result[0].message.contains("level 1 heading"));
1249 }
1250
1251 #[test]
1252 fn test_empty_document() {
1253 let rule = MD041FirstLineHeading::default();
1254
1255 let content = "";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert!(result.is_empty(), "Expected no warnings for empty document");
1260 }
1261
1262 #[test]
1263 fn test_whitespace_only_document() {
1264 let rule = MD041FirstLineHeading::default();
1265
1266 let content = " \n\n \t\n";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1271 }
1272
1273 #[test]
1274 fn test_front_matter_then_whitespace() {
1275 let rule = MD041FirstLineHeading::default();
1276
1277 let content = "---\ntitle: Test\n---\n\n \n\n";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(
1282 result.is_empty(),
1283 "Expected no warnings when no content after front matter"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_multiple_front_matter_types() {
1289 let rule = MD041FirstLineHeading::new(1, true);
1290
1291 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295 assert!(
1296 result.is_empty(),
1297 "Expected no warnings for TOML front matter with title"
1298 );
1299
1300 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303 let result = rule.check(&ctx).unwrap();
1304 assert!(
1305 result.is_empty(),
1306 "Expected no warnings for JSON front matter with title"
1307 );
1308
1309 let content = "---\ntitle: My Document\n---\n\nContent.";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let result = rule.check(&ctx).unwrap();
1313 assert!(
1314 result.is_empty(),
1315 "Expected no warnings for YAML front matter with title"
1316 );
1317 }
1318
1319 #[test]
1320 fn test_toml_front_matter_with_heading() {
1321 let rule = MD041FirstLineHeading::default();
1322
1323 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
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 when heading follows TOML front matter"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_toml_front_matter_without_title_no_heading() {
1335 let rule = MD041FirstLineHeading::new(1, true);
1336
1337 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341 assert_eq!(result.len(), 1);
1342 assert_eq!(result[0].line, 6);
1343 }
1344
1345 #[test]
1346 fn test_toml_front_matter_level_2_heading() {
1347 let rule = MD041FirstLineHeading::new(2, true);
1349
1350 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let result = rule.check(&ctx).unwrap();
1353 assert!(
1354 result.is_empty(),
1355 "Issue #427: TOML front matter with title and correct heading level should not warn"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1361 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1363
1364 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(
1368 result.is_empty(),
1369 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_json_front_matter_with_heading() {
1375 let rule = MD041FirstLineHeading::default();
1376
1377 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let result = rule.check(&ctx).unwrap();
1381 assert!(
1382 result.is_empty(),
1383 "Expected no warnings when heading follows JSON front matter"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_malformed_front_matter() {
1389 let rule = MD041FirstLineHeading::new(1, true);
1390
1391 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let result = rule.check(&ctx).unwrap();
1395 assert!(
1396 result.is_empty(),
1397 "Expected no warnings for malformed front matter with title"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_front_matter_with_heading() {
1403 let rule = MD041FirstLineHeading::default();
1404
1405 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409 assert!(
1410 result.is_empty(),
1411 "Expected no warnings when first line after front matter is correct heading"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_no_fix_suggestion() {
1417 let rule = MD041FirstLineHeading::default();
1418
1419 let content = "Not a heading\n\nContent.";
1421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1422 let result = rule.check(&ctx).unwrap();
1423 assert_eq!(result.len(), 1);
1424 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1425 }
1426
1427 #[test]
1428 fn test_complex_document_structure() {
1429 let rule = MD041FirstLineHeading::default();
1430
1431 let content =
1433 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435 let result = rule.check(&ctx).unwrap();
1436 assert!(
1437 result.is_empty(),
1438 "HTML comments should be skipped, so first heading after comment should be valid"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_heading_with_special_characters() {
1444 let rule = MD041FirstLineHeading::default();
1445
1446 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1449 let result = rule.check(&ctx).unwrap();
1450 assert!(
1451 result.is_empty(),
1452 "Expected no warnings for heading with inline formatting"
1453 );
1454 }
1455
1456 #[test]
1457 fn test_level_configuration() {
1458 for level in 1..=6 {
1460 let rule = MD041FirstLineHeading::new(level, false);
1461
1462 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1464 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1465 let result = rule.check(&ctx).unwrap();
1466 assert!(
1467 result.is_empty(),
1468 "Expected no warnings for correct level {level} heading"
1469 );
1470
1471 let wrong_level = if level == 1 { 2 } else { 1 };
1473 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1474 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1475 let result = rule.check(&ctx).unwrap();
1476 assert_eq!(result.len(), 1);
1477 assert!(result[0].message.contains(&format!("level {level} heading")));
1478 }
1479 }
1480
1481 #[test]
1482 fn test_issue_152_multiline_html_heading() {
1483 let rule = MD041FirstLineHeading::default();
1484
1485 let content = "<h1>\nSome text\n</h1>";
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let result = rule.check(&ctx).unwrap();
1489 assert!(
1490 result.is_empty(),
1491 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_multiline_html_heading_with_attributes() {
1497 let rule = MD041FirstLineHeading::default();
1498
1499 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let result = rule.check(&ctx).unwrap();
1503 assert!(
1504 result.is_empty(),
1505 "Multi-line HTML heading with attributes should be recognized"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_multiline_html_heading_wrong_level() {
1511 let rule = MD041FirstLineHeading::default();
1512
1513 let content = "<h2>\nSome text\n</h2>";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let result = rule.check(&ctx).unwrap();
1517 assert_eq!(result.len(), 1);
1518 assert!(result[0].message.contains("level 1 heading"));
1519 }
1520
1521 #[test]
1522 fn test_multiline_html_heading_with_content_after() {
1523 let rule = MD041FirstLineHeading::default();
1524
1525 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let result = rule.check(&ctx).unwrap();
1529 assert!(
1530 result.is_empty(),
1531 "Multi-line HTML heading followed by content should be valid"
1532 );
1533 }
1534
1535 #[test]
1536 fn test_multiline_html_heading_incomplete() {
1537 let rule = MD041FirstLineHeading::default();
1538
1539 let content = "<h1>\nSome text\n\nMore content without closing tag";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542 let result = rule.check(&ctx).unwrap();
1543 assert_eq!(result.len(), 1);
1544 assert!(result[0].message.contains("level 1 heading"));
1545 }
1546
1547 #[test]
1548 fn test_singleline_html_heading_still_works() {
1549 let rule = MD041FirstLineHeading::default();
1550
1551 let content = "<h1>My Document</h1>\n\nContent.";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554 let result = rule.check(&ctx).unwrap();
1555 assert!(
1556 result.is_empty(),
1557 "Single-line HTML headings should still be recognized"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_multiline_html_heading_with_nested_tags() {
1563 let rule = MD041FirstLineHeading::default();
1564
1565 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 let result = rule.check(&ctx).unwrap();
1569 assert!(
1570 result.is_empty(),
1571 "Multi-line HTML heading with nested tags should be recognized"
1572 );
1573 }
1574
1575 #[test]
1576 fn test_multiline_html_heading_various_levels() {
1577 for level in 1..=6 {
1579 let rule = MD041FirstLineHeading::new(level, false);
1580
1581 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1583 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1584 let result = rule.check(&ctx).unwrap();
1585 assert!(
1586 result.is_empty(),
1587 "Multi-line HTML heading at level {level} should be recognized"
1588 );
1589
1590 let wrong_level = if level == 1 { 2 } else { 1 };
1592 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1593 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1594 let result = rule.check(&ctx).unwrap();
1595 assert_eq!(result.len(), 1);
1596 assert!(result[0].message.contains(&format!("level {level} heading")));
1597 }
1598 }
1599
1600 #[test]
1601 fn test_issue_152_nested_heading_spans_many_lines() {
1602 let rule = MD041FirstLineHeading::default();
1603
1604 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>";
1605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1606 let result = rule.check(&ctx).unwrap();
1607 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1608 }
1609
1610 #[test]
1611 fn test_issue_152_picture_tag_heading() {
1612 let rule = MD041FirstLineHeading::default();
1613
1614 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>";
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616 let result = rule.check(&ctx).unwrap();
1617 assert!(
1618 result.is_empty(),
1619 "Picture tag inside multi-line HTML heading should be recognized"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_badge_images_before_heading() {
1625 let rule = MD041FirstLineHeading::default();
1626
1627 let content = "\n\n# My Project";
1629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630 let result = rule.check(&ctx).unwrap();
1631 assert!(result.is_empty(), "Badge image should be skipped");
1632
1633 let content = " \n\n# My Project";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636 let result = rule.check(&ctx).unwrap();
1637 assert!(result.is_empty(), "Multiple badges should be skipped");
1638
1639 let content = "[](https://example.com)\n\n# My Project";
1641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642 let result = rule.check(&ctx).unwrap();
1643 assert!(result.is_empty(), "Linked badge should be skipped");
1644 }
1645
1646 #[test]
1647 fn test_multiple_badge_lines_before_heading() {
1648 let rule = MD041FirstLineHeading::default();
1649
1650 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653 let result = rule.check(&ctx).unwrap();
1654 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1655 }
1656
1657 #[test]
1658 fn test_badges_without_heading_still_warns() {
1659 let rule = MD041FirstLineHeading::default();
1660
1661 let content = "\n\nThis is not a heading.";
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664 let result = rule.check(&ctx).unwrap();
1665 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1666 }
1667
1668 #[test]
1669 fn test_mixed_content_not_badge_line() {
1670 let rule = MD041FirstLineHeading::default();
1671
1672 let content = " Some text here\n\n# Heading";
1674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1675 let result = rule.check(&ctx).unwrap();
1676 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1677 }
1678
1679 #[test]
1680 fn test_is_badge_image_line_unit() {
1681 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1683 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1684 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1685 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1686
1687 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1689 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1690 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1691 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1692 }
1693
1694 #[test]
1698 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1699 let rule = MD041FirstLineHeading::default();
1700
1701 let content = "[](){ #example }\n# Title";
1703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1704 let result = rule.check(&ctx).unwrap();
1705 assert!(
1706 result.is_empty(),
1707 "MkDocs anchor line should be skipped in MkDocs flavor"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1713 let rule = MD041FirstLineHeading::default();
1714
1715 let content = "[](){ #example }\n# Title";
1717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718 let result = rule.check(&ctx).unwrap();
1719 assert_eq!(
1720 result.len(),
1721 1,
1722 "MkDocs anchor line should NOT be skipped in Standard flavor"
1723 );
1724 }
1725
1726 #[test]
1727 fn test_multiple_mkdocs_anchors_before_heading() {
1728 let rule = MD041FirstLineHeading::default();
1729
1730 let content = "[](){ #first }\n[](){ #second }\n# Title";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1733 let result = rule.check(&ctx).unwrap();
1734 assert!(
1735 result.is_empty(),
1736 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_mkdocs_anchor_with_front_matter() {
1742 let rule = MD041FirstLineHeading::default();
1743
1744 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1747 let result = rule.check(&ctx).unwrap();
1748 assert!(
1749 result.is_empty(),
1750 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1751 );
1752 }
1753
1754 #[test]
1755 fn test_mkdocs_anchor_kramdown_style() {
1756 let rule = MD041FirstLineHeading::default();
1757
1758 let content = "[](){: #anchor }\n# Title";
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1761 let result = rule.check(&ctx).unwrap();
1762 assert!(
1763 result.is_empty(),
1764 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1765 );
1766 }
1767
1768 #[test]
1769 fn test_mkdocs_anchor_without_heading_still_warns() {
1770 let rule = MD041FirstLineHeading::default();
1771
1772 let content = "[](){ #anchor }\nThis is not a heading.";
1774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1775 let result = rule.check(&ctx).unwrap();
1776 assert_eq!(
1777 result.len(),
1778 1,
1779 "MkDocs anchor followed by non-heading should still trigger MD041"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_mkdocs_anchor_with_html_comment() {
1785 let rule = MD041FirstLineHeading::default();
1786
1787 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1790 let result = rule.check(&ctx).unwrap();
1791 assert!(
1792 result.is_empty(),
1793 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1794 );
1795 }
1796
1797 #[test]
1800 fn test_fix_disabled_by_default() {
1801 use crate::rule::Rule;
1802 let rule = MD041FirstLineHeading::default();
1803
1804 let content = "## Wrong Level\n\nContent.";
1806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807 let fixed = rule.fix(&ctx).unwrap();
1808 assert_eq!(fixed, content, "Fix should not change content when disabled");
1809 }
1810
1811 #[test]
1812 fn test_fix_wrong_heading_level() {
1813 use crate::rule::Rule;
1814 let rule = MD041FirstLineHeading {
1815 level: 1,
1816 front_matter_title: false,
1817 front_matter_title_pattern: None,
1818 allow_preamble: false,
1819 fix_enabled: true,
1820 };
1821
1822 let content = "## Wrong Level\n\nContent.\n";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 let fixed = rule.fix(&ctx).unwrap();
1826 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1827 }
1828
1829 #[test]
1830 fn test_fix_heading_after_preamble() {
1831 use crate::rule::Rule;
1832 let rule = MD041FirstLineHeading {
1833 level: 1,
1834 front_matter_title: false,
1835 front_matter_title_pattern: None,
1836 allow_preamble: false,
1837 fix_enabled: true,
1838 };
1839
1840 let content = "\n\n# Title\n\nContent.\n";
1842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1843 let fixed = rule.fix(&ctx).unwrap();
1844 assert!(
1845 fixed.starts_with("# Title\n"),
1846 "Heading should be moved to first line, got: {fixed}"
1847 );
1848 }
1849
1850 #[test]
1851 fn test_fix_heading_after_html_comment() {
1852 use crate::rule::Rule;
1853 let rule = MD041FirstLineHeading {
1854 level: 1,
1855 front_matter_title: false,
1856 front_matter_title_pattern: None,
1857 allow_preamble: false,
1858 fix_enabled: true,
1859 };
1860
1861 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1864 let fixed = rule.fix(&ctx).unwrap();
1865 assert!(
1866 fixed.starts_with("# Title\n"),
1867 "Heading should be moved above comment, got: {fixed}"
1868 );
1869 }
1870
1871 #[test]
1872 fn test_fix_heading_level_and_move() {
1873 use crate::rule::Rule;
1874 let rule = MD041FirstLineHeading {
1875 level: 1,
1876 front_matter_title: false,
1877 front_matter_title_pattern: None,
1878 allow_preamble: false,
1879 fix_enabled: true,
1880 };
1881
1882 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885 let fixed = rule.fix(&ctx).unwrap();
1886 assert!(
1887 fixed.starts_with("# Wrong Level\n"),
1888 "Heading should be fixed and moved, got: {fixed}"
1889 );
1890 }
1891
1892 #[test]
1893 fn test_fix_with_front_matter() {
1894 use crate::rule::Rule;
1895 let rule = MD041FirstLineHeading {
1896 level: 1,
1897 front_matter_title: false,
1898 front_matter_title_pattern: None,
1899 allow_preamble: false,
1900 fix_enabled: true,
1901 };
1902
1903 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906 let fixed = rule.fix(&ctx).unwrap();
1907 assert!(
1908 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1909 "Heading should be right after front matter, got: {fixed}"
1910 );
1911 }
1912
1913 #[test]
1914 fn test_fix_with_toml_front_matter() {
1915 use crate::rule::Rule;
1916 let rule = MD041FirstLineHeading {
1917 level: 1,
1918 front_matter_title: false,
1919 front_matter_title_pattern: None,
1920 allow_preamble: false,
1921 fix_enabled: true,
1922 };
1923
1924 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1927 let fixed = rule.fix(&ctx).unwrap();
1928 assert!(
1929 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1930 "Heading should be right after TOML front matter, got: {fixed}"
1931 );
1932 }
1933
1934 #[test]
1935 fn test_fix_cannot_fix_no_heading() {
1936 use crate::rule::Rule;
1937 let rule = MD041FirstLineHeading {
1938 level: 1,
1939 front_matter_title: false,
1940 front_matter_title_pattern: None,
1941 allow_preamble: false,
1942 fix_enabled: true,
1943 };
1944
1945 let content = "Just some text.\n\nMore text.\n";
1947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948 let fixed = rule.fix(&ctx).unwrap();
1949 assert_eq!(fixed, content, "Should not change content when no heading exists");
1950 }
1951
1952 #[test]
1953 fn test_fix_cannot_fix_content_before_heading() {
1954 use crate::rule::Rule;
1955 let rule = MD041FirstLineHeading {
1956 level: 1,
1957 front_matter_title: false,
1958 front_matter_title_pattern: None,
1959 allow_preamble: false,
1960 fix_enabled: true,
1961 };
1962
1963 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1966 let fixed = rule.fix(&ctx).unwrap();
1967 assert_eq!(
1968 fixed, content,
1969 "Should not change content when real content exists before heading"
1970 );
1971 }
1972
1973 #[test]
1974 fn test_fix_already_correct() {
1975 use crate::rule::Rule;
1976 let rule = MD041FirstLineHeading {
1977 level: 1,
1978 front_matter_title: false,
1979 front_matter_title_pattern: None,
1980 allow_preamble: false,
1981 fix_enabled: true,
1982 };
1983
1984 let content = "# Title\n\nContent.\n";
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987 let fixed = rule.fix(&ctx).unwrap();
1988 assert_eq!(fixed, content, "Should not change already correct content");
1989 }
1990
1991 #[test]
1992 fn test_fix_setext_heading_removes_underline() {
1993 use crate::rule::Rule;
1994 let rule = MD041FirstLineHeading {
1995 level: 1,
1996 front_matter_title: false,
1997 front_matter_title_pattern: None,
1998 allow_preamble: false,
1999 fix_enabled: true,
2000 };
2001
2002 let content = "Wrong Level\n-----------\n\nContent.\n";
2004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2005 let fixed = rule.fix(&ctx).unwrap();
2006 assert_eq!(
2007 fixed, "# Wrong Level\n\nContent.\n",
2008 "Setext heading should be converted to ATX and underline removed"
2009 );
2010 }
2011
2012 #[test]
2013 fn test_fix_setext_h1_heading() {
2014 use crate::rule::Rule;
2015 let rule = MD041FirstLineHeading {
2016 level: 1,
2017 front_matter_title: false,
2018 front_matter_title_pattern: None,
2019 allow_preamble: false,
2020 fix_enabled: true,
2021 };
2022
2023 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
2025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2026 let fixed = rule.fix(&ctx).unwrap();
2027 assert_eq!(
2028 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
2029 "Setext h1 should be moved and converted to ATX"
2030 );
2031 }
2032
2033 #[test]
2034 fn test_html_heading_not_claimed_fixable() {
2035 use crate::rule::Rule;
2036 let rule = MD041FirstLineHeading {
2037 level: 1,
2038 front_matter_title: false,
2039 front_matter_title_pattern: None,
2040 allow_preamble: false,
2041 fix_enabled: true,
2042 };
2043
2044 let content = "<h2>Title</h2>\n\nContent.\n";
2046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047 let warnings = rule.check(&ctx).unwrap();
2048 assert_eq!(warnings.len(), 1);
2049 assert!(
2050 warnings[0].fix.is_none(),
2051 "HTML heading should not be claimed as fixable"
2052 );
2053 }
2054
2055 #[test]
2056 fn test_no_heading_not_claimed_fixable() {
2057 use crate::rule::Rule;
2058 let rule = MD041FirstLineHeading {
2059 level: 1,
2060 front_matter_title: false,
2061 front_matter_title_pattern: None,
2062 allow_preamble: false,
2063 fix_enabled: true,
2064 };
2065
2066 let content = "Just some text.\n\nMore text.\n";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069 let warnings = rule.check(&ctx).unwrap();
2070 assert_eq!(warnings.len(), 1);
2071 assert!(
2072 warnings[0].fix.is_none(),
2073 "Document without heading should not be claimed as fixable"
2074 );
2075 }
2076
2077 #[test]
2078 fn test_content_before_heading_not_claimed_fixable() {
2079 use crate::rule::Rule;
2080 let rule = MD041FirstLineHeading {
2081 level: 1,
2082 front_matter_title: false,
2083 front_matter_title_pattern: None,
2084 allow_preamble: false,
2085 fix_enabled: true,
2086 };
2087
2088 let content = "Intro text.\n\n## Heading\n\nMore.\n";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091 let warnings = rule.check(&ctx).unwrap();
2092 assert_eq!(warnings.len(), 1);
2093 assert!(
2094 warnings[0].fix.is_none(),
2095 "Document with content before heading should not be claimed as fixable"
2096 );
2097 }
2098
2099 #[test]
2102 fn test_fix_html_block_before_heading_is_now_fixable() {
2103 use crate::rule::Rule;
2104 let rule = MD041FirstLineHeading {
2105 level: 1,
2106 front_matter_title: false,
2107 front_matter_title_pattern: None,
2108 allow_preamble: false,
2109 fix_enabled: true,
2110 };
2111
2112 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
2114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2115
2116 let warnings = rule.check(&ctx).unwrap();
2117 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
2118 assert!(
2119 warnings[0].fix.is_some(),
2120 "Should be fixable: heading exists after HTML block preamble"
2121 );
2122
2123 let fixed = rule.fix(&ctx).unwrap();
2124 assert!(
2125 fixed.starts_with("# My Document\n"),
2126 "Heading should be moved to the top, got: {fixed}"
2127 );
2128 }
2129
2130 #[test]
2131 fn test_fix_html_block_wrong_level_before_heading() {
2132 use crate::rule::Rule;
2133 let rule = MD041FirstLineHeading {
2134 level: 1,
2135 front_matter_title: false,
2136 front_matter_title_pattern: None,
2137 allow_preamble: false,
2138 fix_enabled: true,
2139 };
2140
2141 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
2142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2143 let fixed = rule.fix(&ctx).unwrap();
2144 assert!(
2145 fixed.starts_with("# Wrong Level\n"),
2146 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
2147 );
2148 }
2149
2150 #[test]
2153 fn test_fix_promote_plain_text_title() {
2154 use crate::rule::Rule;
2155 let rule = MD041FirstLineHeading {
2156 level: 1,
2157 front_matter_title: false,
2158 front_matter_title_pattern: None,
2159 allow_preamble: false,
2160 fix_enabled: true,
2161 };
2162
2163 let content = "My Project\n\nSome content.\n";
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165
2166 let warnings = rule.check(&ctx).unwrap();
2167 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2168 assert!(
2169 warnings[0].fix.is_some(),
2170 "Should be fixable: first line is a title candidate"
2171 );
2172
2173 let fixed = rule.fix(&ctx).unwrap();
2174 assert_eq!(
2175 fixed, "# My Project\n\nSome content.\n",
2176 "Title line should be promoted to heading"
2177 );
2178 }
2179
2180 #[test]
2181 fn test_fix_promote_plain_text_title_with_front_matter() {
2182 use crate::rule::Rule;
2183 let rule = MD041FirstLineHeading {
2184 level: 1,
2185 front_matter_title: false,
2186 front_matter_title_pattern: None,
2187 allow_preamble: false,
2188 fix_enabled: true,
2189 };
2190
2191 let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2193 let fixed = rule.fix(&ctx).unwrap();
2194 assert!(
2195 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2196 "Title should be promoted and placed right after front matter, got: {fixed}"
2197 );
2198 }
2199
2200 #[test]
2201 fn test_fix_no_promote_ends_with_period() {
2202 use crate::rule::Rule;
2203 let rule = MD041FirstLineHeading {
2204 level: 1,
2205 front_matter_title: false,
2206 front_matter_title_pattern: None,
2207 allow_preamble: false,
2208 fix_enabled: true,
2209 };
2210
2211 let content = "This is a sentence.\n\nContent.\n";
2213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2214 let fixed = rule.fix(&ctx).unwrap();
2215 assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2216
2217 let warnings = rule.check(&ctx).unwrap();
2218 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2219 }
2220
2221 #[test]
2222 fn test_fix_no_promote_ends_with_colon() {
2223 use crate::rule::Rule;
2224 let rule = MD041FirstLineHeading {
2225 level: 1,
2226 front_matter_title: false,
2227 front_matter_title_pattern: None,
2228 allow_preamble: false,
2229 fix_enabled: true,
2230 };
2231
2232 let content = "Note:\n\nContent.\n";
2233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2234 let fixed = rule.fix(&ctx).unwrap();
2235 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2236 }
2237
2238 #[test]
2239 fn test_fix_no_promote_if_too_long() {
2240 use crate::rule::Rule;
2241 let rule = MD041FirstLineHeading {
2242 level: 1,
2243 front_matter_title: false,
2244 front_matter_title_pattern: None,
2245 allow_preamble: false,
2246 fix_enabled: true,
2247 };
2248
2249 let long_line = "A".repeat(81);
2251 let content = format!("{long_line}\n\nContent.\n");
2252 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2253 let fixed = rule.fix(&ctx).unwrap();
2254 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2255 }
2256
2257 #[test]
2258 fn test_fix_no_promote_ordered_list_item() {
2259 use crate::rule::Rule;
2260 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2261
2262 for content in [
2264 "1. Introduction\n\nBody.\n",
2265 "1) Introduction\n\nBody.\n",
2266 "123456789. Ninth step\n\nBody.\n",
2267 ] {
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269 assert!(!rule.check(&ctx).unwrap().is_empty(), "missing H1 is still reported");
2270 let fixed = rule.fix(&ctx).unwrap();
2271 assert_eq!(fixed, content, "an ordered list item must not become a heading");
2272 }
2273
2274 let content = "1234567890. Release Notes\n\nBody.\n";
2276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2277 assert_eq!(rule.fix(&ctx).unwrap(), "# 1234567890. Release Notes\n\nBody.\n");
2278 }
2279
2280 #[test]
2283 fn test_fix_no_promote_structural_lines() {
2284 use crate::rule::Rule;
2285 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2286
2287 for content in [
2288 ">Quote\n\nBody.\n",
2289 "> Quote\n\nBody.\n",
2290 "- Item\n\nBody.\n",
2291 "* Item\n\nBody.\n",
2292 "+ Item\n\nBody.\n",
2293 " - Indented item\n\nBody.\n",
2294 "***\n\nBody.\n",
2295 "---\n\nBody.\n",
2296 "```\n\nBody.\n",
2297 " code\n\nBody.\n",
2298 ] {
2299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2300 let warnings = rule.check(&ctx).unwrap();
2301 assert_eq!(warnings.len(), 1, "missing H1 is still reported for {content:?}");
2302 assert!(warnings[0].fix.is_none(), "no fix may be offered for {content:?}");
2303 assert_eq!(
2304 rule.fix(&ctx).unwrap(),
2305 content,
2306 "{content:?} must not become a heading"
2307 );
2308 }
2309
2310 let ctx = LintContext::new("Plain Title\n\nBody.\n", crate::config::MarkdownFlavor::Standard, None);
2312 assert_eq!(rule.fix(&ctx).unwrap(), "# Plain Title\n\nBody.\n");
2313 }
2314
2315 #[test]
2316 fn test_fix_promotes_multibyte_title_within_character_limit() {
2317 use crate::rule::Rule;
2318 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2319
2320 let title = "日本語のタイトル".repeat(7);
2322 let content = format!("{title}\n\nBody.\n");
2323 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2324 let fixed = rule.fix(&ctx).unwrap();
2325 assert_eq!(fixed, format!("# {title}\n\nBody.\n"));
2326 }
2327
2328 #[test]
2329 fn test_fix_no_promote_if_no_blank_after() {
2330 use crate::rule::Rule;
2331 let rule = MD041FirstLineHeading {
2332 level: 1,
2333 front_matter_title: false,
2334 front_matter_title_pattern: None,
2335 allow_preamble: false,
2336 fix_enabled: true,
2337 };
2338
2339 let content = "My Project\nImmediately continues.\n\nContent.\n";
2341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342 let fixed = rule.fix(&ctx).unwrap();
2343 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2344 }
2345
2346 #[test]
2347 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2348 use crate::rule::Rule;
2349 let rule = MD041FirstLineHeading {
2350 level: 1,
2351 front_matter_title: false,
2352 front_matter_title_pattern: None,
2353 allow_preamble: false,
2354 fix_enabled: true,
2355 };
2356
2357 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2361 let fixed = rule.fix(&ctx).unwrap();
2362 assert_eq!(
2363 fixed, content,
2364 "Should not fix when title candidate exists before a heading"
2365 );
2366
2367 let warnings = rule.check(&ctx).unwrap();
2368 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2369 }
2370
2371 #[test]
2372 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2373 use crate::rule::Rule;
2374 let rule = MD041FirstLineHeading {
2375 level: 1,
2376 front_matter_title: false,
2377 front_matter_title_pattern: None,
2378 allow_preamble: false,
2379 fix_enabled: true,
2380 };
2381
2382 let content = "My Project";
2384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2385 let fixed = rule.fix(&ctx).unwrap();
2386 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2387 }
2388
2389 #[test]
2392 fn test_fix_insert_derived_directive_only_document() {
2393 use crate::rule::Rule;
2394 use std::path::PathBuf;
2395 let rule = MD041FirstLineHeading {
2396 level: 1,
2397 front_matter_title: false,
2398 front_matter_title_pattern: None,
2399 allow_preamble: false,
2400 fix_enabled: true,
2401 };
2402
2403 let content = "!!! note\n This is a note.\n";
2406 let ctx = LintContext::new(
2407 content,
2408 crate::config::MarkdownFlavor::MkDocs,
2409 Some(PathBuf::from("setup-guide.md")),
2410 );
2411
2412 let can_fix = rule.can_fix(&ctx);
2413 assert!(can_fix, "Directive-only document with source file should be fixable");
2414
2415 let fixed = rule.fix(&ctx).unwrap();
2416 assert!(
2417 fixed.starts_with("# Setup Guide\n"),
2418 "Should insert derived heading, got: {fixed}"
2419 );
2420 }
2421
2422 #[test]
2423 fn test_fix_no_insert_derived_without_source_file() {
2424 use crate::rule::Rule;
2425 let rule = MD041FirstLineHeading {
2426 level: 1,
2427 front_matter_title: false,
2428 front_matter_title_pattern: None,
2429 allow_preamble: false,
2430 fix_enabled: true,
2431 };
2432
2433 let content = "!!! note\n This is a note.\n";
2435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2436 let fixed = rule.fix(&ctx).unwrap();
2437 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2438 }
2439
2440 #[test]
2441 fn test_fix_no_insert_derived_when_has_real_content() {
2442 use crate::rule::Rule;
2443 use std::path::PathBuf;
2444 let rule = MD041FirstLineHeading {
2445 level: 1,
2446 front_matter_title: false,
2447 front_matter_title_pattern: None,
2448 allow_preamble: false,
2449 fix_enabled: true,
2450 };
2451
2452 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2454 let ctx = LintContext::new(
2455 content,
2456 crate::config::MarkdownFlavor::MkDocs,
2457 Some(PathBuf::from("guide.md")),
2458 );
2459 let fixed = rule.fix(&ctx).unwrap();
2460 assert_eq!(
2461 fixed, content,
2462 "Should not insert derived heading when real content is present"
2463 );
2464 }
2465
2466 #[test]
2467 fn test_derive_title_converts_kebab_case() {
2468 use std::path::PathBuf;
2469 let ctx = LintContext::new(
2470 "",
2471 crate::config::MarkdownFlavor::Standard,
2472 Some(PathBuf::from("my-setup-guide.md")),
2473 );
2474 let title = MD041FirstLineHeading::derive_title(&ctx);
2475 assert_eq!(title, Some("My Setup Guide".to_string()));
2476 }
2477
2478 #[test]
2479 fn test_derive_title_converts_underscores() {
2480 use std::path::PathBuf;
2481 let ctx = LintContext::new(
2482 "",
2483 crate::config::MarkdownFlavor::Standard,
2484 Some(PathBuf::from("api_reference.md")),
2485 );
2486 let title = MD041FirstLineHeading::derive_title(&ctx);
2487 assert_eq!(title, Some("Api Reference".to_string()));
2488 }
2489
2490 #[test]
2491 fn test_derive_title_none_without_source_file() {
2492 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2493 let title = MD041FirstLineHeading::derive_title(&ctx);
2494 assert_eq!(title, None);
2495 }
2496
2497 #[test]
2498 fn test_derive_title_index_file_uses_parent_dir() {
2499 use std::path::PathBuf;
2500 let ctx = LintContext::new(
2501 "",
2502 crate::config::MarkdownFlavor::Standard,
2503 Some(PathBuf::from("docs/getting-started/index.md")),
2504 );
2505 let title = MD041FirstLineHeading::derive_title(&ctx);
2506 assert_eq!(title, Some("Getting Started".to_string()));
2507 }
2508
2509 #[test]
2510 fn test_derive_title_readme_file_uses_parent_dir() {
2511 use std::path::PathBuf;
2512 let ctx = LintContext::new(
2513 "",
2514 crate::config::MarkdownFlavor::Standard,
2515 Some(PathBuf::from("my-project/README.md")),
2516 );
2517 let title = MD041FirstLineHeading::derive_title(&ctx);
2518 assert_eq!(title, Some("My Project".to_string()));
2519 }
2520
2521 #[test]
2522 fn test_derive_title_index_without_parent_returns_none() {
2523 use std::path::PathBuf;
2524 let ctx = LintContext::new(
2526 "",
2527 crate::config::MarkdownFlavor::Standard,
2528 Some(PathBuf::from("index.md")),
2529 );
2530 let title = MD041FirstLineHeading::derive_title(&ctx);
2531 assert_eq!(title, None);
2532 }
2533
2534 #[test]
2535 fn test_derive_title_readme_without_parent_returns_none() {
2536 use std::path::PathBuf;
2537 let ctx = LintContext::new(
2538 "",
2539 crate::config::MarkdownFlavor::Standard,
2540 Some(PathBuf::from("README.md")),
2541 );
2542 let title = MD041FirstLineHeading::derive_title(&ctx);
2543 assert_eq!(title, None);
2544 }
2545
2546 #[test]
2547 fn test_derive_title_readme_case_insensitive() {
2548 use std::path::PathBuf;
2549 let ctx = LintContext::new(
2551 "",
2552 crate::config::MarkdownFlavor::Standard,
2553 Some(PathBuf::from("docs/api/readme.md")),
2554 );
2555 let title = MD041FirstLineHeading::derive_title(&ctx);
2556 assert_eq!(title, Some("Api".to_string()));
2557 }
2558
2559 #[test]
2560 fn test_is_title_candidate_basic() {
2561 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2562 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2563 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2564 }
2565
2566 #[test]
2567 fn test_is_title_candidate_rejects_sentence_punctuation() {
2568 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2569 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2570 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2571 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2572 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2573 }
2574
2575 #[test]
2576 fn test_is_title_candidate_rejects_when_no_blank_after() {
2577 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2578 }
2579
2580 #[test]
2581 fn test_is_title_candidate_rejects_long_lines() {
2582 let long = "A".repeat(81);
2583 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2584 let ok = "A".repeat(80);
2586 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2587 }
2588
2589 #[test]
2590 fn test_is_title_candidate_judges_text_shape_only() {
2591 assert!(MD041FirstLineHeading::is_title_candidate("2026 Roadmap", true));
2594 assert!(MD041FirstLineHeading::is_title_candidate("1.0 Release Notes", true));
2595 assert!(MD041FirstLineHeading::is_title_candidate("C++ Notes", true));
2596 }
2597
2598 #[test]
2599 fn test_is_title_candidate_length_counts_characters_not_bytes() {
2600 let cjk = "日本語のタイトル".repeat(7);
2602 assert_eq!(cjk.chars().count(), 56);
2603 assert!(MD041FirstLineHeading::is_title_candidate(&cjk, true));
2604 let long = "日".repeat(81);
2606 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2607 }
2608
2609 #[test]
2610 fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2611 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2614 let content = "My Document Title\n\nMore content follows.";
2616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2617 let warnings = rule.check(&ctx).unwrap();
2618 assert_eq!(warnings.len(), 1);
2619 let fix = warnings[0]
2620 .fix
2621 .as_ref()
2622 .expect("Fix should be present for promotable text");
2623 assert!(
2624 !fix.replacement.is_empty(),
2625 "Fix replacement must not be empty — applying it directly must produce valid output"
2626 );
2627 assert!(
2628 fix.replacement.starts_with("# "),
2629 "Fix replacement should be a level-1 heading, got: {:?}",
2630 fix.replacement
2631 );
2632 assert_eq!(fix.replacement, "# My Document Title");
2633 }
2634
2635 #[test]
2636 fn test_fix_replacement_not_empty_for_releveling() {
2637 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2640 let content = "## Wrong Level\n\nContent.";
2641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2642 let warnings = rule.check(&ctx).unwrap();
2643 assert_eq!(warnings.len(), 1);
2644 let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2645 assert!(
2646 !fix.replacement.is_empty(),
2647 "Fix replacement must not be empty for releveling"
2648 );
2649 assert_eq!(fix.replacement, "# Wrong Level");
2650 }
2651
2652 #[test]
2653 fn test_fix_replacement_applied_produces_valid_output() {
2654 let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2656 let content = "My Document\n\nMore content.";
2658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2659
2660 let warnings = rule.check(&ctx).unwrap();
2661 assert_eq!(warnings.len(), 1);
2662 let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2663
2664 let mut patched = content.to_string();
2666 patched.replace_range(fix.range.clone(), &fix.replacement);
2667
2668 let fixed = rule.fix(&ctx).unwrap();
2670
2671 assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2672 }
2673
2674 #[test]
2675 fn test_mdx_disable_on_line_1_no_heading() {
2676 let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2681
2682 let rule = MD041FirstLineHeading::default();
2684 let warnings = rule.check(&ctx).unwrap();
2685 if !warnings.is_empty() {
2690 assert_eq!(
2691 warnings[0].line, 2,
2692 "Warning must be on line 2 (first content line after MDX comment), not line 1"
2693 );
2694 }
2695 }
2696
2697 #[test]
2698 fn test_mdx_disable_fix_returns_unchanged() {
2699 let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2702 let rule = MD041FirstLineHeading {
2703 fix_enabled: true,
2704 ..MD041FirstLineHeading::default()
2705 };
2706 let result = rule.fix(&ctx).unwrap();
2707 assert_eq!(
2708 result, content,
2709 "fix() should not modify content when MD041 is disabled via MDX comment"
2710 );
2711 }
2712
2713 #[test]
2714 fn test_mdx_comment_without_disable_heading_on_next_line() {
2715 let rule = MD041FirstLineHeading::default();
2716
2717 let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2720 let result = rule.check(&ctx).unwrap();
2721 assert!(
2722 result.is_empty(),
2723 "MDX comment is preamble; heading on next line should satisfy MD041"
2724 );
2725 }
2726
2727 #[test]
2728 fn test_mdx_comment_without_heading_triggers_warning() {
2729 let rule = MD041FirstLineHeading::default();
2730
2731 let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2734 let result = rule.check(&ctx).unwrap();
2735 assert_eq!(
2736 result.len(),
2737 1,
2738 "MDX comment followed by non-heading should trigger MD041"
2739 );
2740 assert_eq!(
2741 result[0].line, 2,
2742 "Warning should be on line 2 (the first content line after MDX comment)"
2743 );
2744 }
2745
2746 #[test]
2747 fn test_multiline_mdx_comment_followed_by_heading() {
2748 let rule = MD041FirstLineHeading::default();
2749
2750 let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
2752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2753 let result = rule.check(&ctx).unwrap();
2754 assert!(
2755 result.is_empty(),
2756 "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
2757 );
2758 }
2759
2760 #[test]
2761 fn test_html_comment_still_works_as_preamble_regression() {
2762 let rule = MD041FirstLineHeading::default();
2763
2764 let content = "<!-- Some comment -->\n# My Document\n\nContent.";
2766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2767 let result = rule.check(&ctx).unwrap();
2768 assert!(
2769 result.is_empty(),
2770 "HTML comment should still be treated as preamble (regression test)"
2771 );
2772 }
2773
2774 #[test]
2775 fn test_mdg_requires_first_h1() {
2776 let rule = MD041FirstLineHeading::default();
2779 let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
2780
2781 let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2782 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2783
2784 assert!(!rule.check(&mdg_ctx).unwrap().is_empty());
2785 assert_eq!(
2786 rule.check(&mdg_ctx).unwrap().len(),
2787 rule.check(&standard_ctx).unwrap().len(),
2788 "MDG must not differ from Standard"
2789 );
2790 }
2791
2792 #[test]
2793 fn test_mdg_fix_relevels_without_relocating_content() {
2794 let rule = MD041FirstLineHeading {
2797 fix_enabled: true,
2798 ..MD041FirstLineHeading::default()
2799 };
2800 let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
2801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2802
2803 let fixed = rule.fix(&ctx).unwrap();
2804 assert_eq!(fixed, "# Feature: Checkout\n\n##### Scenario: Purchase\n");
2805
2806 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2807 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2808 }
2809}