1mod md041_config;
2
3pub use md041_config::MD041Config;
4
5use crate::lint_context::HeadingStyle;
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
7use crate::rules::front_matter_utils::FrontMatterUtils;
8use crate::utils::mkdocs_attr_list::is_mkdocs_anchor_line;
9use crate::utils::range_utils::calculate_line_range;
10use crate::utils::regex_cache::HTML_HEADING_PATTERN;
11use regex::Regex;
12
13#[derive(Clone)]
18pub struct MD041FirstLineHeading {
19 pub level: usize,
20 pub front_matter_title: bool,
21 pub front_matter_title_pattern: Option<Regex>,
22 pub fix_enabled: bool,
23}
24
25impl Default for MD041FirstLineHeading {
26 fn default() -> Self {
27 Self {
28 level: 1,
29 front_matter_title: true,
30 front_matter_title_pattern: None,
31 fix_enabled: false,
32 }
33 }
34}
35
36enum FixPlan {
38 MoveOrRelevel {
40 front_matter_end_idx: usize,
41 heading_idx: usize,
42 is_setext: bool,
43 current_level: usize,
44 needs_level_fix: bool,
45 },
46 PromotePlainText {
48 front_matter_end_idx: usize,
49 title_line_idx: usize,
50 title_text: String,
51 },
52 InsertDerived {
55 front_matter_end_idx: usize,
56 derived_title: String,
57 },
58}
59
60impl MD041FirstLineHeading {
61 pub fn new(level: usize, front_matter_title: bool) -> Self {
62 Self {
63 level,
64 front_matter_title,
65 front_matter_title_pattern: None,
66 fix_enabled: false,
67 }
68 }
69
70 pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
71 let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
72 Ok(regex) => Some(regex),
73 Err(e) => {
74 log::warn!("Invalid front_matter_title_pattern regex: {e}");
75 None
76 }
77 });
78
79 Self {
80 level,
81 front_matter_title,
82 front_matter_title_pattern,
83 fix_enabled,
84 }
85 }
86
87 fn has_front_matter_title(&self, content: &str) -> bool {
88 if !self.front_matter_title {
89 return false;
90 }
91
92 if let Some(ref pattern) = self.front_matter_title_pattern {
94 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
95 for line in front_matter_lines {
96 if pattern.is_match(line) {
97 return true;
98 }
99 }
100 return false;
101 }
102
103 FrontMatterUtils::has_front_matter_field(content, "title:")
105 }
106
107 fn is_non_content_line(line: &str) -> bool {
109 let trimmed = line.trim();
110
111 if trimmed.starts_with('[') && trimmed.contains("]: ") {
113 return true;
114 }
115
116 if trimmed.starts_with('*') && trimmed.contains("]: ") {
118 return true;
119 }
120
121 if Self::is_badge_image_line(trimmed) {
124 return true;
125 }
126
127 false
128 }
129
130 fn is_badge_image_line(line: &str) -> bool {
136 if line.is_empty() {
137 return false;
138 }
139
140 if !line.starts_with('!') && !line.starts_with('[') {
142 return false;
143 }
144
145 let mut remaining = line;
147 while !remaining.is_empty() {
148 remaining = remaining.trim_start();
149 if remaining.is_empty() {
150 break;
151 }
152
153 if remaining.starts_with("[![") {
155 if let Some(end) = Self::find_linked_image_end(remaining) {
156 remaining = &remaining[end..];
157 continue;
158 }
159 return false;
160 }
161
162 if remaining.starts_with("![") {
164 if let Some(end) = Self::find_image_end(remaining) {
165 remaining = &remaining[end..];
166 continue;
167 }
168 return false;
169 }
170
171 return false;
173 }
174
175 true
176 }
177
178 fn find_image_end(s: &str) -> Option<usize> {
180 if !s.starts_with("![") {
181 return None;
182 }
183 let alt_end = s[2..].find("](")?;
185 let paren_start = 2 + alt_end + 2; let paren_end = s[paren_start..].find(')')?;
188 Some(paren_start + paren_end + 1)
189 }
190
191 fn find_linked_image_end(s: &str) -> Option<usize> {
193 if !s.starts_with("[![") {
194 return None;
195 }
196 let inner_end = Self::find_image_end(&s[1..])?;
198 let after_inner = 1 + inner_end;
199 if !s[after_inner..].starts_with("](") {
201 return None;
202 }
203 let link_start = after_inner + 2;
204 let link_end = s[link_start..].find(')')?;
205 Some(link_start + link_end + 1)
206 }
207
208 fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
210 let trimmed = line.trim_start();
211
212 if trimmed.starts_with('#') {
214 let hashes = "#".repeat(target_level);
215 let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
217 let after_hashes = &trimmed[content_start..];
218 let content = after_hashes.trim_start();
219
220 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
222 format!("{leading_ws}{hashes} {content}")
223 } else {
224 let hashes = "#".repeat(target_level);
227 let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
228 format!("{leading_ws}{hashes} {trimmed}")
229 }
230 }
231
232 fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
240 if text.is_empty() {
241 return false;
242 }
243
244 if !next_is_blank_or_eof {
245 return false;
246 }
247
248 if text.len() > 80 {
249 return false;
250 }
251
252 let last_char = text.chars().next_back().unwrap_or(' ');
253 if matches!(last_char, '.' | '?' | '!' | ':' | ';') {
254 return false;
255 }
256
257 if text.starts_with('#')
259 || text.starts_with("- ")
260 || text.starts_with("* ")
261 || text.starts_with("+ ")
262 || text.starts_with("> ")
263 {
264 return false;
265 }
266
267 true
268 }
269
270 fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
274 let stem = ctx
275 .source_file
276 .as_ref()
277 .and_then(|p| p.file_stem())
278 .and_then(|s| s.to_str())?;
279
280 let title: String = stem
281 .split(['-', '_'])
282 .filter(|w| !w.is_empty())
283 .map(|word| {
284 let mut chars = word.chars();
285 match chars.next() {
286 None => String::new(),
287 Some(first) => {
288 let upper: String = first.to_uppercase().collect();
289 upper + chars.as_str()
290 }
291 }
292 })
293 .collect::<Vec<_>>()
294 .join(" ");
295
296 if title.is_empty() { None } else { Some(title) }
297 }
298
299 fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
301 let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
303 if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
304 && let Some(h_level) = captures.get(1)
305 && h_level.as_str().parse::<usize>().unwrap_or(0) == level
306 {
307 return true;
308 }
309
310 let html_tags = ctx.html_tags();
312 let target_tag = format!("h{level}");
313
314 let opening_index = html_tags.iter().position(|tag| {
316 tag.line == first_line_idx + 1 && tag.tag_name == target_tag
318 && !tag.is_closing
319 });
320
321 let Some(open_idx) = opening_index else {
322 return false;
323 };
324
325 let mut depth = 1usize;
328 for tag in html_tags.iter().skip(open_idx + 1) {
329 if tag.line <= first_line_idx + 1 {
331 continue;
332 }
333
334 if tag.tag_name == target_tag {
335 if tag.is_closing {
336 depth -= 1;
337 if depth == 0 {
338 return true;
339 }
340 } else if !tag.is_self_closing {
341 depth += 1;
342 }
343 }
344 }
345
346 false
347 }
348
349 fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
351 if ctx.lines.is_empty() {
352 return None;
353 }
354
355 let mut front_matter_end_idx = 0;
357 for line_info in &ctx.lines {
358 if line_info.in_front_matter {
359 front_matter_end_idx += 1;
360 } else {
361 break;
362 }
363 }
364
365 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
366
367 let mut found_heading: Option<(usize, bool, usize)> = None;
369 let mut first_title_candidate: Option<(usize, String)> = None;
371 let mut found_non_title_content = false;
373 let mut saw_non_directive_content = false;
375
376 'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
377 let line_content = line_info.content(ctx.content);
378 let trimmed = line_content.trim();
379
380 let is_preamble = trimmed.is_empty()
382 || line_info.in_html_comment
383 || line_info.in_html_block
384 || Self::is_non_content_line(line_content)
385 || (is_mkdocs && is_mkdocs_anchor_line(line_content))
386 || line_info.in_kramdown_extension_block
387 || line_info.is_kramdown_block_ial;
388
389 if is_preamble {
390 continue;
391 }
392
393 let is_directive_block = line_info.in_admonition
396 || line_info.in_content_tab
397 || line_info.in_quarto_div
398 || line_info.is_div_marker
399 || line_info.in_pymdown_block;
400
401 if !is_directive_block {
402 saw_non_directive_content = true;
403 }
404
405 if let Some(heading) = &line_info.heading {
407 let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
408 found_heading = Some((idx, is_setext, heading.level as usize));
409 break 'scan;
410 }
411
412 if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
414 let next_is_blank_or_eof = ctx
415 .lines
416 .get(idx + 1)
417 .is_none_or(|l| l.content(ctx.content).trim().is_empty());
418
419 if Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
420 first_title_candidate = Some((idx, trimmed.to_string()));
421 } else {
422 found_non_title_content = true;
423 }
424 }
425 }
426
427 if let Some((h_idx, is_setext, current_level)) = found_heading {
428 if found_non_title_content || first_title_candidate.is_some() {
432 return None;
433 }
434
435 let needs_level_fix = current_level != self.level;
436 let needs_move = h_idx > front_matter_end_idx;
437
438 if needs_level_fix || needs_move {
439 return Some(FixPlan::MoveOrRelevel {
440 front_matter_end_idx,
441 heading_idx: h_idx,
442 is_setext,
443 current_level,
444 needs_level_fix,
445 });
446 }
447 return None; }
449
450 if let Some((title_idx, title_text)) = first_title_candidate {
453 return Some(FixPlan::PromotePlainText {
454 front_matter_end_idx,
455 title_line_idx: title_idx,
456 title_text,
457 });
458 }
459
460 if !saw_non_directive_content && let Some(derived_title) = Self::derive_title(ctx) {
463 return Some(FixPlan::InsertDerived {
464 front_matter_end_idx,
465 derived_title,
466 });
467 }
468
469 None
470 }
471
472 fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
474 self.fix_enabled && self.analyze_for_fix(ctx).is_some()
475 }
476}
477
478impl Rule for MD041FirstLineHeading {
479 fn name(&self) -> &'static str {
480 "MD041"
481 }
482
483 fn description(&self) -> &'static str {
484 "First line in file should be a top level heading"
485 }
486
487 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
488 let mut warnings = Vec::new();
489
490 if self.should_skip(ctx) {
492 return Ok(warnings);
493 }
494
495 let mut first_content_line_num = None;
497 let mut skip_lines = 0;
498
499 for line_info in &ctx.lines {
501 if line_info.in_front_matter {
502 skip_lines += 1;
503 } else {
504 break;
505 }
506 }
507
508 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
510
511 for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
512 let line_content = line_info.content(ctx.content);
513 let trimmed = line_content.trim();
514 if line_info.in_esm_block {
516 continue;
517 }
518 if line_info.in_html_comment {
520 continue;
521 }
522 if is_mkdocs && is_mkdocs_anchor_line(line_content) {
524 continue;
525 }
526 if line_info.in_kramdown_extension_block || line_info.is_kramdown_block_ial {
528 continue;
529 }
530 if !trimmed.is_empty() && !Self::is_non_content_line(line_content) {
531 first_content_line_num = Some(line_num);
532 break;
533 }
534 }
535
536 if first_content_line_num.is_none() {
537 return Ok(warnings);
539 }
540
541 let first_line_idx = first_content_line_num.unwrap();
542
543 let first_line_info = &ctx.lines[first_line_idx];
545 let is_correct_heading = if let Some(heading) = &first_line_info.heading {
546 heading.level as usize == self.level
547 } else {
548 Self::is_html_heading(ctx, first_line_idx, self.level)
550 };
551
552 if !is_correct_heading {
553 let first_line = first_line_idx + 1; let first_line_content = first_line_info.content(ctx.content);
556 let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
557
558 let fix = if self.can_fix(ctx) {
561 let range_start = first_line_info.byte_offset;
562 let range_end = range_start + first_line_info.byte_len;
563 Some(Fix {
564 range: range_start..range_end,
565 replacement: String::new(), })
567 } else {
568 None
569 };
570
571 warnings.push(LintWarning {
572 rule_name: Some(self.name().to_string()),
573 line: start_line,
574 column: start_col,
575 end_line,
576 end_column: end_col,
577 message: format!("First line in file should be a level {} heading", self.level),
578 severity: Severity::Warning,
579 fix,
580 });
581 }
582 Ok(warnings)
583 }
584
585 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
586 if !self.fix_enabled {
587 return Ok(ctx.content.to_string());
588 }
589
590 if self.should_skip(ctx) {
591 return Ok(ctx.content.to_string());
592 }
593
594 let Some(plan) = self.analyze_for_fix(ctx) else {
595 return Ok(ctx.content.to_string());
596 };
597
598 let lines = ctx.raw_lines();
599
600 let mut result = String::new();
601 let preserve_trailing_newline = ctx.content.ends_with('\n');
602
603 match plan {
604 FixPlan::MoveOrRelevel {
605 front_matter_end_idx,
606 heading_idx,
607 is_setext,
608 current_level,
609 needs_level_fix,
610 } => {
611 let heading_line = ctx.lines[heading_idx].content(ctx.content);
612 let fixed_heading = if needs_level_fix || is_setext {
613 self.fix_heading_level(heading_line, current_level, self.level)
614 } else {
615 heading_line.to_string()
616 };
617
618 for line in lines.iter().take(front_matter_end_idx) {
619 result.push_str(line);
620 result.push('\n');
621 }
622 result.push_str(&fixed_heading);
623 result.push('\n');
624 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
625 if idx == heading_idx {
626 continue;
627 }
628 if is_setext && idx == heading_idx + 1 {
629 continue;
630 }
631 result.push_str(line);
632 result.push('\n');
633 }
634 }
635
636 FixPlan::PromotePlainText {
637 front_matter_end_idx,
638 title_line_idx,
639 title_text,
640 } => {
641 let hashes = "#".repeat(self.level);
642 let new_heading = format!("{hashes} {title_text}");
643
644 for line in lines.iter().take(front_matter_end_idx) {
645 result.push_str(line);
646 result.push('\n');
647 }
648 result.push_str(&new_heading);
649 result.push('\n');
650 for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
651 if idx == title_line_idx {
652 continue;
653 }
654 result.push_str(line);
655 result.push('\n');
656 }
657 }
658
659 FixPlan::InsertDerived {
660 front_matter_end_idx,
661 derived_title,
662 } => {
663 let hashes = "#".repeat(self.level);
664 let new_heading = format!("{hashes} {derived_title}");
665
666 for line in lines.iter().take(front_matter_end_idx) {
667 result.push_str(line);
668 result.push('\n');
669 }
670 result.push_str(&new_heading);
671 result.push('\n');
672 result.push('\n');
673 for line in lines.iter().skip(front_matter_end_idx) {
674 result.push_str(line);
675 result.push('\n');
676 }
677 }
678 }
679
680 if !preserve_trailing_newline && result.ends_with('\n') {
681 result.pop();
682 }
683
684 Ok(result)
685 }
686
687 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
689 let only_directives = !ctx.content.is_empty()
694 && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
695 let t = l.trim();
696 (t.starts_with("{{#") && t.ends_with("}}"))
698 || (t.starts_with("<!--") && t.ends_with("-->"))
700 });
701
702 ctx.content.is_empty()
703 || (self.front_matter_title && self.has_front_matter_title(ctx.content))
704 || only_directives
705 }
706
707 fn as_any(&self) -> &dyn std::any::Any {
708 self
709 }
710
711 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
712 where
713 Self: Sized,
714 {
715 let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
717
718 let use_front_matter = !md041_config.front_matter_title.is_empty();
719
720 Box::new(MD041FirstLineHeading::with_pattern(
721 md041_config.level.as_usize(),
722 use_front_matter,
723 md041_config.front_matter_title_pattern,
724 md041_config.fix,
725 ))
726 }
727
728 fn default_config_section(&self) -> Option<(String, toml::Value)> {
729 Some((
730 "MD041".to_string(),
731 toml::toml! {
732 level = 1
733 front-matter-title = "title"
734 front-matter-title-pattern = ""
735 fix = false
736 }
737 .into(),
738 ))
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use crate::lint_context::LintContext;
746
747 #[test]
748 fn test_first_line_is_heading_correct_level() {
749 let rule = MD041FirstLineHeading::default();
750
751 let content = "# My Document\n\nSome content here.";
753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755 assert!(
756 result.is_empty(),
757 "Expected no warnings when first line is a level 1 heading"
758 );
759 }
760
761 #[test]
762 fn test_first_line_is_heading_wrong_level() {
763 let rule = MD041FirstLineHeading::default();
764
765 let content = "## My Document\n\nSome content here.";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert_eq!(result.len(), 1);
770 assert_eq!(result[0].line, 1);
771 assert!(result[0].message.contains("level 1 heading"));
772 }
773
774 #[test]
775 fn test_first_line_not_heading() {
776 let rule = MD041FirstLineHeading::default();
777
778 let content = "This is not a heading\n\n# This is a heading";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert_eq!(result.len(), 1);
783 assert_eq!(result[0].line, 1);
784 assert!(result[0].message.contains("level 1 heading"));
785 }
786
787 #[test]
788 fn test_empty_lines_before_heading() {
789 let rule = MD041FirstLineHeading::default();
790
791 let content = "\n\n# My Document\n\nSome content.";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795 assert!(
796 result.is_empty(),
797 "Expected no warnings when empty lines precede a valid heading"
798 );
799
800 let content = "\n\nNot a heading\n\nSome content.";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert_eq!(result.len(), 1);
805 assert_eq!(result[0].line, 3); assert!(result[0].message.contains("level 1 heading"));
807 }
808
809 #[test]
810 fn test_front_matter_with_title() {
811 let rule = MD041FirstLineHeading::new(1, true);
812
813 let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817 assert!(
818 result.is_empty(),
819 "Expected no warnings when front matter has title field"
820 );
821 }
822
823 #[test]
824 fn test_front_matter_without_title() {
825 let rule = MD041FirstLineHeading::new(1, true);
826
827 let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830 let result = rule.check(&ctx).unwrap();
831 assert_eq!(result.len(), 1);
832 assert_eq!(result[0].line, 6); }
834
835 #[test]
836 fn test_front_matter_disabled() {
837 let rule = MD041FirstLineHeading::new(1, false);
838
839 let content = "---\ntitle: My Document\n---\n\nSome content here.";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert_eq!(result.len(), 1);
844 assert_eq!(result[0].line, 5); }
846
847 #[test]
848 fn test_html_comments_before_heading() {
849 let rule = MD041FirstLineHeading::default();
850
851 let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert!(
856 result.is_empty(),
857 "HTML comments should be skipped when checking for first heading"
858 );
859 }
860
861 #[test]
862 fn test_multiline_html_comment_before_heading() {
863 let rule = MD041FirstLineHeading::default();
864
865 let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert!(
870 result.is_empty(),
871 "Multi-line HTML comments should be skipped when checking for first heading"
872 );
873 }
874
875 #[test]
876 fn test_html_comment_with_blank_lines_before_heading() {
877 let rule = MD041FirstLineHeading::default();
878
879 let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let result = rule.check(&ctx).unwrap();
883 assert!(
884 result.is_empty(),
885 "HTML comments with blank lines should be skipped when checking for first heading"
886 );
887 }
888
889 #[test]
890 fn test_html_comment_before_html_heading() {
891 let rule = MD041FirstLineHeading::default();
892
893 let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897 assert!(
898 result.is_empty(),
899 "HTML comments should be skipped before HTML headings"
900 );
901 }
902
903 #[test]
904 fn test_document_with_only_html_comments() {
905 let rule = MD041FirstLineHeading::default();
906
907 let content = "<!-- This is a comment -->\n<!-- Another comment -->";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911 assert!(
912 result.is_empty(),
913 "Documents with only HTML comments should not trigger MD041"
914 );
915 }
916
917 #[test]
918 fn test_html_comment_followed_by_non_heading() {
919 let rule = MD041FirstLineHeading::default();
920
921 let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925 assert_eq!(
926 result.len(),
927 1,
928 "HTML comment followed by non-heading should still trigger MD041"
929 );
930 assert_eq!(
931 result[0].line, 2,
932 "Warning should be on the first non-comment, non-heading line"
933 );
934 }
935
936 #[test]
937 fn test_multiple_html_comments_before_heading() {
938 let rule = MD041FirstLineHeading::default();
939
940 let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944 assert!(
945 result.is_empty(),
946 "Multiple HTML comments should all be skipped before heading"
947 );
948 }
949
950 #[test]
951 fn test_html_comment_with_wrong_level_heading() {
952 let rule = MD041FirstLineHeading::default();
953
954 let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957 let result = rule.check(&ctx).unwrap();
958 assert_eq!(
959 result.len(),
960 1,
961 "HTML comment followed by wrong-level heading should still trigger MD041"
962 );
963 assert!(
964 result[0].message.contains("level 1 heading"),
965 "Should require level 1 heading"
966 );
967 }
968
969 #[test]
970 fn test_html_comment_mixed_with_reference_definitions() {
971 let rule = MD041FirstLineHeading::default();
972
973 let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert!(
978 result.is_empty(),
979 "HTML comments and reference definitions should both be skipped before heading"
980 );
981 }
982
983 #[test]
984 fn test_html_comment_after_front_matter() {
985 let rule = MD041FirstLineHeading::default();
986
987 let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991 assert!(
992 result.is_empty(),
993 "HTML comments after front matter should be skipped before heading"
994 );
995 }
996
997 #[test]
998 fn test_html_comment_not_at_start_should_not_affect_rule() {
999 let rule = MD041FirstLineHeading::default();
1000
1001 let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005 assert!(
1006 result.is_empty(),
1007 "HTML comments in middle of document should not affect MD041 (only first content matters)"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_multiline_html_comment_followed_by_non_heading() {
1013 let rule = MD041FirstLineHeading::default();
1014
1015 let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019 assert_eq!(
1020 result.len(),
1021 1,
1022 "Multi-line HTML comment followed by non-heading should still trigger MD041"
1023 );
1024 assert_eq!(
1025 result[0].line, 5,
1026 "Warning should be on the first non-comment, non-heading line"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_different_heading_levels() {
1032 let rule = MD041FirstLineHeading::new(2, false);
1034
1035 let content = "## Second Level Heading\n\nContent.";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1039
1040 let content = "# First Level Heading\n\nContent.";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 let result = rule.check(&ctx).unwrap();
1044 assert_eq!(result.len(), 1);
1045 assert!(result[0].message.contains("level 2 heading"));
1046 }
1047
1048 #[test]
1049 fn test_setext_headings() {
1050 let rule = MD041FirstLineHeading::default();
1051
1052 let content = "My Document\n===========\n\nContent.";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056 assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1057
1058 let content = "My Document\n-----------\n\nContent.";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061 let result = rule.check(&ctx).unwrap();
1062 assert_eq!(result.len(), 1);
1063 assert!(result[0].message.contains("level 1 heading"));
1064 }
1065
1066 #[test]
1067 fn test_empty_document() {
1068 let rule = MD041FirstLineHeading::default();
1069
1070 let content = "";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let result = rule.check(&ctx).unwrap();
1074 assert!(result.is_empty(), "Expected no warnings for empty document");
1075 }
1076
1077 #[test]
1078 fn test_whitespace_only_document() {
1079 let rule = MD041FirstLineHeading::default();
1080
1081 let content = " \n\n \t\n";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085 assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1086 }
1087
1088 #[test]
1089 fn test_front_matter_then_whitespace() {
1090 let rule = MD041FirstLineHeading::default();
1091
1092 let content = "---\ntitle: Test\n---\n\n \n\n";
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 "Expected no warnings when no content after front matter"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_multiple_front_matter_types() {
1104 let rule = MD041FirstLineHeading::new(1, true);
1105
1106 let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert!(
1111 result.is_empty(),
1112 "Expected no warnings for TOML front matter with title"
1113 );
1114
1115 let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert!(
1120 result.is_empty(),
1121 "Expected no warnings for JSON front matter with title"
1122 );
1123
1124 let content = "---\ntitle: My Document\n---\n\nContent.";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127 let result = rule.check(&ctx).unwrap();
1128 assert!(
1129 result.is_empty(),
1130 "Expected no warnings for YAML front matter with title"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_toml_front_matter_with_heading() {
1136 let rule = MD041FirstLineHeading::default();
1137
1138 let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142 assert!(
1143 result.is_empty(),
1144 "Expected no warnings when heading follows TOML front matter"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_toml_front_matter_without_title_no_heading() {
1150 let rule = MD041FirstLineHeading::new(1, true);
1151
1152 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 let result = rule.check(&ctx).unwrap();
1156 assert_eq!(result.len(), 1);
1157 assert_eq!(result[0].line, 6);
1158 }
1159
1160 #[test]
1161 fn test_toml_front_matter_level_2_heading() {
1162 let rule = MD041FirstLineHeading::new(2, true);
1164
1165 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
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 "Issue #427: TOML front matter with title and correct heading level should not warn"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1176 let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1178
1179 let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
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 "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1185 );
1186 }
1187
1188 #[test]
1189 fn test_json_front_matter_with_heading() {
1190 let rule = MD041FirstLineHeading::default();
1191
1192 let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195 let result = rule.check(&ctx).unwrap();
1196 assert!(
1197 result.is_empty(),
1198 "Expected no warnings when heading follows JSON front matter"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_malformed_front_matter() {
1204 let rule = MD041FirstLineHeading::new(1, true);
1205
1206 let content = "- --\ntitle: My Document\n- --\n\nContent.";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210 assert!(
1211 result.is_empty(),
1212 "Expected no warnings for malformed front matter with title"
1213 );
1214 }
1215
1216 #[test]
1217 fn test_front_matter_with_heading() {
1218 let rule = MD041FirstLineHeading::default();
1219
1220 let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224 assert!(
1225 result.is_empty(),
1226 "Expected no warnings when first line after front matter is correct heading"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_no_fix_suggestion() {
1232 let rule = MD041FirstLineHeading::default();
1233
1234 let content = "Not a heading\n\nContent.";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238 assert_eq!(result.len(), 1);
1239 assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1240 }
1241
1242 #[test]
1243 fn test_complex_document_structure() {
1244 let rule = MD041FirstLineHeading::default();
1245
1246 let content =
1248 "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251 assert!(
1252 result.is_empty(),
1253 "HTML comments should be skipped, so first heading after comment should be valid"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_heading_with_special_characters() {
1259 let rule = MD041FirstLineHeading::default();
1260
1261 let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 let result = rule.check(&ctx).unwrap();
1265 assert!(
1266 result.is_empty(),
1267 "Expected no warnings for heading with inline formatting"
1268 );
1269 }
1270
1271 #[test]
1272 fn test_level_configuration() {
1273 for level in 1..=6 {
1275 let rule = MD041FirstLineHeading::new(level, false);
1276
1277 let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
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 for correct level {level} heading"
1284 );
1285
1286 let wrong_level = if level == 1 { 2 } else { 1 };
1288 let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1289 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1290 let result = rule.check(&ctx).unwrap();
1291 assert_eq!(result.len(), 1);
1292 assert!(result[0].message.contains(&format!("level {level} heading")));
1293 }
1294 }
1295
1296 #[test]
1297 fn test_issue_152_multiline_html_heading() {
1298 let rule = MD041FirstLineHeading::default();
1299
1300 let content = "<h1>\nSome text\n</h1>";
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 "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1307 );
1308 }
1309
1310 #[test]
1311 fn test_multiline_html_heading_with_attributes() {
1312 let rule = MD041FirstLineHeading::default();
1313
1314 let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1317 let result = rule.check(&ctx).unwrap();
1318 assert!(
1319 result.is_empty(),
1320 "Multi-line HTML heading with attributes should be recognized"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_multiline_html_heading_wrong_level() {
1326 let rule = MD041FirstLineHeading::default();
1327
1328 let content = "<h2>\nSome text\n</h2>";
1330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331 let result = rule.check(&ctx).unwrap();
1332 assert_eq!(result.len(), 1);
1333 assert!(result[0].message.contains("level 1 heading"));
1334 }
1335
1336 #[test]
1337 fn test_multiline_html_heading_with_content_after() {
1338 let rule = MD041FirstLineHeading::default();
1339
1340 let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343 let result = rule.check(&ctx).unwrap();
1344 assert!(
1345 result.is_empty(),
1346 "Multi-line HTML heading followed by content should be valid"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_multiline_html_heading_incomplete() {
1352 let rule = MD041FirstLineHeading::default();
1353
1354 let content = "<h1>\nSome text\n\nMore content without closing tag";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358 assert_eq!(result.len(), 1);
1359 assert!(result[0].message.contains("level 1 heading"));
1360 }
1361
1362 #[test]
1363 fn test_singleline_html_heading_still_works() {
1364 let rule = MD041FirstLineHeading::default();
1365
1366 let content = "<h1>My Document</h1>\n\nContent.";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert!(
1371 result.is_empty(),
1372 "Single-line HTML headings should still be recognized"
1373 );
1374 }
1375
1376 #[test]
1377 fn test_multiline_html_heading_with_nested_tags() {
1378 let rule = MD041FirstLineHeading::default();
1379
1380 let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383 let result = rule.check(&ctx).unwrap();
1384 assert!(
1385 result.is_empty(),
1386 "Multi-line HTML heading with nested tags should be recognized"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_multiline_html_heading_various_levels() {
1392 for level in 1..=6 {
1394 let rule = MD041FirstLineHeading::new(level, false);
1395
1396 let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1398 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1399 let result = rule.check(&ctx).unwrap();
1400 assert!(
1401 result.is_empty(),
1402 "Multi-line HTML heading at level {level} should be recognized"
1403 );
1404
1405 let wrong_level = if level == 1 { 2 } else { 1 };
1407 let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1408 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1409 let result = rule.check(&ctx).unwrap();
1410 assert_eq!(result.len(), 1);
1411 assert!(result[0].message.contains(&format!("level {level} heading")));
1412 }
1413 }
1414
1415 #[test]
1416 fn test_issue_152_nested_heading_spans_many_lines() {
1417 let rule = MD041FirstLineHeading::default();
1418
1419 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>";
1420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421 let result = rule.check(&ctx).unwrap();
1422 assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1423 }
1424
1425 #[test]
1426 fn test_issue_152_picture_tag_heading() {
1427 let rule = MD041FirstLineHeading::default();
1428
1429 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>";
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431 let result = rule.check(&ctx).unwrap();
1432 assert!(
1433 result.is_empty(),
1434 "Picture tag inside multi-line HTML heading should be recognized"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_badge_images_before_heading() {
1440 let rule = MD041FirstLineHeading::default();
1441
1442 let content = "\n\n# My Project";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446 assert!(result.is_empty(), "Badge image should be skipped");
1447
1448 let content = " \n\n# My Project";
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 let result = rule.check(&ctx).unwrap();
1452 assert!(result.is_empty(), "Multiple badges should be skipped");
1453
1454 let content = "[](https://example.com)\n\n# My Project";
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 let result = rule.check(&ctx).unwrap();
1458 assert!(result.is_empty(), "Linked badge should be skipped");
1459 }
1460
1461 #[test]
1462 fn test_multiple_badge_lines_before_heading() {
1463 let rule = MD041FirstLineHeading::default();
1464
1465 let content = "[](https://crates.io)\n[](https://docs.rs)\n\n# My Project";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468 let result = rule.check(&ctx).unwrap();
1469 assert!(result.is_empty(), "Multiple badge lines should be skipped");
1470 }
1471
1472 #[test]
1473 fn test_badges_without_heading_still_warns() {
1474 let rule = MD041FirstLineHeading::default();
1475
1476 let content = "\n\nThis is not a heading.";
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479 let result = rule.check(&ctx).unwrap();
1480 assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1481 }
1482
1483 #[test]
1484 fn test_mixed_content_not_badge_line() {
1485 let rule = MD041FirstLineHeading::default();
1486
1487 let content = " Some text here\n\n# Heading";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let result = rule.check(&ctx).unwrap();
1491 assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1492 }
1493
1494 #[test]
1495 fn test_is_badge_image_line_unit() {
1496 assert!(MD041FirstLineHeading::is_badge_image_line(""));
1498 assert!(MD041FirstLineHeading::is_badge_image_line("[](link)"));
1499 assert!(MD041FirstLineHeading::is_badge_image_line(" "));
1500 assert!(MD041FirstLineHeading::is_badge_image_line("[](c) [](f)"));
1501
1502 assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1504 assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1505 assert!(!MD041FirstLineHeading::is_badge_image_line(" text"));
1506 assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1507 }
1508
1509 #[test]
1513 fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1514 let rule = MD041FirstLineHeading::default();
1515
1516 let content = "[](){ #example }\n# Title";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1519 let result = rule.check(&ctx).unwrap();
1520 assert!(
1521 result.is_empty(),
1522 "MkDocs anchor line should be skipped in MkDocs flavor"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1528 let rule = MD041FirstLineHeading::default();
1529
1530 let content = "[](){ #example }\n# Title";
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533 let result = rule.check(&ctx).unwrap();
1534 assert_eq!(
1535 result.len(),
1536 1,
1537 "MkDocs anchor line should NOT be skipped in Standard flavor"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_multiple_mkdocs_anchors_before_heading() {
1543 let rule = MD041FirstLineHeading::default();
1544
1545 let content = "[](){ #first }\n[](){ #second }\n# Title";
1547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1548 let result = rule.check(&ctx).unwrap();
1549 assert!(
1550 result.is_empty(),
1551 "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1552 );
1553 }
1554
1555 #[test]
1556 fn test_mkdocs_anchor_with_front_matter() {
1557 let rule = MD041FirstLineHeading::default();
1558
1559 let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1562 let result = rule.check(&ctx).unwrap();
1563 assert!(
1564 result.is_empty(),
1565 "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1566 );
1567 }
1568
1569 #[test]
1570 fn test_mkdocs_anchor_kramdown_style() {
1571 let rule = MD041FirstLineHeading::default();
1572
1573 let content = "[](){: #anchor }\n# Title";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1576 let result = rule.check(&ctx).unwrap();
1577 assert!(
1578 result.is_empty(),
1579 "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_mkdocs_anchor_without_heading_still_warns() {
1585 let rule = MD041FirstLineHeading::default();
1586
1587 let content = "[](){ #anchor }\nThis is not a heading.";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1590 let result = rule.check(&ctx).unwrap();
1591 assert_eq!(
1592 result.len(),
1593 1,
1594 "MkDocs anchor followed by non-heading should still trigger MD041"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_mkdocs_anchor_with_html_comment() {
1600 let rule = MD041FirstLineHeading::default();
1601
1602 let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1605 let result = rule.check(&ctx).unwrap();
1606 assert!(
1607 result.is_empty(),
1608 "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1609 );
1610 }
1611
1612 #[test]
1615 fn test_fix_disabled_by_default() {
1616 use crate::rule::Rule;
1617 let rule = MD041FirstLineHeading::default();
1618
1619 let content = "## Wrong Level\n\nContent.";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let fixed = rule.fix(&ctx).unwrap();
1623 assert_eq!(fixed, content, "Fix should not change content when disabled");
1624 }
1625
1626 #[test]
1627 fn test_fix_wrong_heading_level() {
1628 use crate::rule::Rule;
1629 let rule = MD041FirstLineHeading {
1630 level: 1,
1631 front_matter_title: false,
1632 front_matter_title_pattern: None,
1633 fix_enabled: true,
1634 };
1635
1636 let content = "## Wrong Level\n\nContent.\n";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let fixed = rule.fix(&ctx).unwrap();
1640 assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1641 }
1642
1643 #[test]
1644 fn test_fix_heading_after_preamble() {
1645 use crate::rule::Rule;
1646 let rule = MD041FirstLineHeading {
1647 level: 1,
1648 front_matter_title: false,
1649 front_matter_title_pattern: None,
1650 fix_enabled: true,
1651 };
1652
1653 let content = "\n\n# Title\n\nContent.\n";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let fixed = rule.fix(&ctx).unwrap();
1657 assert!(
1658 fixed.starts_with("# Title\n"),
1659 "Heading should be moved to first line, got: {fixed}"
1660 );
1661 }
1662
1663 #[test]
1664 fn test_fix_heading_after_html_comment() {
1665 use crate::rule::Rule;
1666 let rule = MD041FirstLineHeading {
1667 level: 1,
1668 front_matter_title: false,
1669 front_matter_title_pattern: None,
1670 fix_enabled: true,
1671 };
1672
1673 let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let fixed = rule.fix(&ctx).unwrap();
1677 assert!(
1678 fixed.starts_with("# Title\n"),
1679 "Heading should be moved above comment, got: {fixed}"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_fix_heading_level_and_move() {
1685 use crate::rule::Rule;
1686 let rule = MD041FirstLineHeading {
1687 level: 1,
1688 front_matter_title: false,
1689 front_matter_title_pattern: None,
1690 fix_enabled: true,
1691 };
1692
1693 let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1696 let fixed = rule.fix(&ctx).unwrap();
1697 assert!(
1698 fixed.starts_with("# Wrong Level\n"),
1699 "Heading should be fixed and moved, got: {fixed}"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_fix_with_front_matter() {
1705 use crate::rule::Rule;
1706 let rule = MD041FirstLineHeading {
1707 level: 1,
1708 front_matter_title: false,
1709 front_matter_title_pattern: None,
1710 fix_enabled: true,
1711 };
1712
1713 let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1716 let fixed = rule.fix(&ctx).unwrap();
1717 assert!(
1718 fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1719 "Heading should be right after front matter, got: {fixed}"
1720 );
1721 }
1722
1723 #[test]
1724 fn test_fix_with_toml_front_matter() {
1725 use crate::rule::Rule;
1726 let rule = MD041FirstLineHeading {
1727 level: 1,
1728 front_matter_title: false,
1729 front_matter_title_pattern: None,
1730 fix_enabled: true,
1731 };
1732
1733 let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1736 let fixed = rule.fix(&ctx).unwrap();
1737 assert!(
1738 fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1739 "Heading should be right after TOML front matter, got: {fixed}"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_fix_cannot_fix_no_heading() {
1745 use crate::rule::Rule;
1746 let rule = MD041FirstLineHeading {
1747 level: 1,
1748 front_matter_title: false,
1749 front_matter_title_pattern: None,
1750 fix_enabled: true,
1751 };
1752
1753 let content = "Just some text.\n\nMore text.\n";
1755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1756 let fixed = rule.fix(&ctx).unwrap();
1757 assert_eq!(fixed, content, "Should not change content when no heading exists");
1758 }
1759
1760 #[test]
1761 fn test_fix_cannot_fix_content_before_heading() {
1762 use crate::rule::Rule;
1763 let rule = MD041FirstLineHeading {
1764 level: 1,
1765 front_matter_title: false,
1766 front_matter_title_pattern: None,
1767 fix_enabled: true,
1768 };
1769
1770 let content = "Some intro text.\n\n# Title\n\nContent.\n";
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let fixed = rule.fix(&ctx).unwrap();
1774 assert_eq!(
1775 fixed, content,
1776 "Should not change content when real content exists before heading"
1777 );
1778 }
1779
1780 #[test]
1781 fn test_fix_already_correct() {
1782 use crate::rule::Rule;
1783 let rule = MD041FirstLineHeading {
1784 level: 1,
1785 front_matter_title: false,
1786 front_matter_title_pattern: None,
1787 fix_enabled: true,
1788 };
1789
1790 let content = "# Title\n\nContent.\n";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let fixed = rule.fix(&ctx).unwrap();
1794 assert_eq!(fixed, content, "Should not change already correct content");
1795 }
1796
1797 #[test]
1798 fn test_fix_setext_heading_removes_underline() {
1799 use crate::rule::Rule;
1800 let rule = MD041FirstLineHeading {
1801 level: 1,
1802 front_matter_title: false,
1803 front_matter_title_pattern: None,
1804 fix_enabled: true,
1805 };
1806
1807 let content = "Wrong Level\n-----------\n\nContent.\n";
1809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1810 let fixed = rule.fix(&ctx).unwrap();
1811 assert_eq!(
1812 fixed, "# Wrong Level\n\nContent.\n",
1813 "Setext heading should be converted to ATX and underline removed"
1814 );
1815 }
1816
1817 #[test]
1818 fn test_fix_setext_h1_heading() {
1819 use crate::rule::Rule;
1820 let rule = MD041FirstLineHeading {
1821 level: 1,
1822 front_matter_title: false,
1823 front_matter_title_pattern: None,
1824 fix_enabled: true,
1825 };
1826
1827 let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let fixed = rule.fix(&ctx).unwrap();
1831 assert_eq!(
1832 fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1833 "Setext h1 should be moved and converted to ATX"
1834 );
1835 }
1836
1837 #[test]
1838 fn test_html_heading_not_claimed_fixable() {
1839 use crate::rule::Rule;
1840 let rule = MD041FirstLineHeading {
1841 level: 1,
1842 front_matter_title: false,
1843 front_matter_title_pattern: None,
1844 fix_enabled: true,
1845 };
1846
1847 let content = "<h2>Title</h2>\n\nContent.\n";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850 let warnings = rule.check(&ctx).unwrap();
1851 assert_eq!(warnings.len(), 1);
1852 assert!(
1853 warnings[0].fix.is_none(),
1854 "HTML heading should not be claimed as fixable"
1855 );
1856 }
1857
1858 #[test]
1859 fn test_no_heading_not_claimed_fixable() {
1860 use crate::rule::Rule;
1861 let rule = MD041FirstLineHeading {
1862 level: 1,
1863 front_matter_title: false,
1864 front_matter_title_pattern: None,
1865 fix_enabled: true,
1866 };
1867
1868 let content = "Just some text.\n\nMore text.\n";
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let warnings = rule.check(&ctx).unwrap();
1872 assert_eq!(warnings.len(), 1);
1873 assert!(
1874 warnings[0].fix.is_none(),
1875 "Document without heading should not be claimed as fixable"
1876 );
1877 }
1878
1879 #[test]
1880 fn test_content_before_heading_not_claimed_fixable() {
1881 use crate::rule::Rule;
1882 let rule = MD041FirstLineHeading {
1883 level: 1,
1884 front_matter_title: false,
1885 front_matter_title_pattern: None,
1886 fix_enabled: true,
1887 };
1888
1889 let content = "Intro text.\n\n## Heading\n\nMore.\n";
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let warnings = rule.check(&ctx).unwrap();
1893 assert_eq!(warnings.len(), 1);
1894 assert!(
1895 warnings[0].fix.is_none(),
1896 "Document with content before heading should not be claimed as fixable"
1897 );
1898 }
1899
1900 #[test]
1903 fn test_fix_html_block_before_heading_is_now_fixable() {
1904 use crate::rule::Rule;
1905 let rule = MD041FirstLineHeading {
1906 level: 1,
1907 front_matter_title: false,
1908 front_matter_title_pattern: None,
1909 fix_enabled: true,
1910 };
1911
1912 let content = "<div>\n Some HTML\n</div>\n\n# My Document\n\nContent.\n";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915
1916 let warnings = rule.check(&ctx).unwrap();
1917 assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
1918 assert!(
1919 warnings[0].fix.is_some(),
1920 "Should be fixable: heading exists after HTML block preamble"
1921 );
1922
1923 let fixed = rule.fix(&ctx).unwrap();
1924 assert!(
1925 fixed.starts_with("# My Document\n"),
1926 "Heading should be moved to the top, got: {fixed}"
1927 );
1928 }
1929
1930 #[test]
1931 fn test_fix_html_block_wrong_level_before_heading() {
1932 use crate::rule::Rule;
1933 let rule = MD041FirstLineHeading {
1934 level: 1,
1935 front_matter_title: false,
1936 front_matter_title_pattern: None,
1937 fix_enabled: true,
1938 };
1939
1940 let content = "<div>\n badge\n</div>\n\n## Wrong Level\n\nContent.\n";
1941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1942 let fixed = rule.fix(&ctx).unwrap();
1943 assert!(
1944 fixed.starts_with("# Wrong Level\n"),
1945 "Heading should be fixed to level 1 and moved to top, got: {fixed}"
1946 );
1947 }
1948
1949 #[test]
1952 fn test_fix_promote_plain_text_title() {
1953 use crate::rule::Rule;
1954 let rule = MD041FirstLineHeading {
1955 level: 1,
1956 front_matter_title: false,
1957 front_matter_title_pattern: None,
1958 fix_enabled: true,
1959 };
1960
1961 let content = "My Project\n\nSome content.\n";
1962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1963
1964 let warnings = rule.check(&ctx).unwrap();
1965 assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
1966 assert!(
1967 warnings[0].fix.is_some(),
1968 "Should be fixable: first line is a title candidate"
1969 );
1970
1971 let fixed = rule.fix(&ctx).unwrap();
1972 assert_eq!(
1973 fixed, "# My Project\n\nSome content.\n",
1974 "Title line should be promoted to heading"
1975 );
1976 }
1977
1978 #[test]
1979 fn test_fix_promote_plain_text_title_with_front_matter() {
1980 use crate::rule::Rule;
1981 let rule = MD041FirstLineHeading {
1982 level: 1,
1983 front_matter_title: false,
1984 front_matter_title_pattern: None,
1985 fix_enabled: true,
1986 };
1987
1988 let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
1989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1990 let fixed = rule.fix(&ctx).unwrap();
1991 assert!(
1992 fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
1993 "Title should be promoted and placed right after front matter, got: {fixed}"
1994 );
1995 }
1996
1997 #[test]
1998 fn test_fix_no_promote_ends_with_period() {
1999 use crate::rule::Rule;
2000 let rule = MD041FirstLineHeading {
2001 level: 1,
2002 front_matter_title: false,
2003 front_matter_title_pattern: None,
2004 fix_enabled: true,
2005 };
2006
2007 let content = "This is a sentence.\n\nContent.\n";
2009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2010 let fixed = rule.fix(&ctx).unwrap();
2011 assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2012
2013 let warnings = rule.check(&ctx).unwrap();
2014 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2015 }
2016
2017 #[test]
2018 fn test_fix_no_promote_ends_with_colon() {
2019 use crate::rule::Rule;
2020 let rule = MD041FirstLineHeading {
2021 level: 1,
2022 front_matter_title: false,
2023 front_matter_title_pattern: None,
2024 fix_enabled: true,
2025 };
2026
2027 let content = "Note:\n\nContent.\n";
2028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2029 let fixed = rule.fix(&ctx).unwrap();
2030 assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2031 }
2032
2033 #[test]
2034 fn test_fix_no_promote_if_too_long() {
2035 use crate::rule::Rule;
2036 let rule = MD041FirstLineHeading {
2037 level: 1,
2038 front_matter_title: false,
2039 front_matter_title_pattern: None,
2040 fix_enabled: true,
2041 };
2042
2043 let long_line = "A".repeat(81);
2045 let content = format!("{long_line}\n\nContent.\n");
2046 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2047 let fixed = rule.fix(&ctx).unwrap();
2048 assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2049 }
2050
2051 #[test]
2052 fn test_fix_no_promote_if_no_blank_after() {
2053 use crate::rule::Rule;
2054 let rule = MD041FirstLineHeading {
2055 level: 1,
2056 front_matter_title: false,
2057 front_matter_title_pattern: None,
2058 fix_enabled: true,
2059 };
2060
2061 let content = "My Project\nImmediately continues.\n\nContent.\n";
2063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2064 let fixed = rule.fix(&ctx).unwrap();
2065 assert_eq!(fixed, content, "Line without following blank should not be promoted");
2066 }
2067
2068 #[test]
2069 fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2070 use crate::rule::Rule;
2071 let rule = MD041FirstLineHeading {
2072 level: 1,
2073 front_matter_title: false,
2074 front_matter_title_pattern: None,
2075 fix_enabled: true,
2076 };
2077
2078 let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2082 let fixed = rule.fix(&ctx).unwrap();
2083 assert_eq!(
2084 fixed, content,
2085 "Should not fix when title candidate exists before a heading"
2086 );
2087
2088 let warnings = rule.check(&ctx).unwrap();
2089 assert!(warnings[0].fix.is_none(), "No fix should be offered");
2090 }
2091
2092 #[test]
2093 fn test_fix_promote_title_at_eof_no_trailing_newline() {
2094 use crate::rule::Rule;
2095 let rule = MD041FirstLineHeading {
2096 level: 1,
2097 front_matter_title: false,
2098 front_matter_title_pattern: None,
2099 fix_enabled: true,
2100 };
2101
2102 let content = "My Project";
2104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105 let fixed = rule.fix(&ctx).unwrap();
2106 assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2107 }
2108
2109 #[test]
2112 fn test_fix_insert_derived_directive_only_document() {
2113 use crate::rule::Rule;
2114 use std::path::PathBuf;
2115 let rule = MD041FirstLineHeading {
2116 level: 1,
2117 front_matter_title: false,
2118 front_matter_title_pattern: None,
2119 fix_enabled: true,
2120 };
2121
2122 let content = "!!! note\n This is a note.\n";
2125 let ctx = LintContext::new(
2126 content,
2127 crate::config::MarkdownFlavor::MkDocs,
2128 Some(PathBuf::from("setup-guide.md")),
2129 );
2130
2131 let can_fix = rule.can_fix(&ctx);
2132 assert!(can_fix, "Directive-only document with source file should be fixable");
2133
2134 let fixed = rule.fix(&ctx).unwrap();
2135 assert!(
2136 fixed.starts_with("# Setup Guide\n"),
2137 "Should insert derived heading, got: {fixed}"
2138 );
2139 }
2140
2141 #[test]
2142 fn test_fix_no_insert_derived_without_source_file() {
2143 use crate::rule::Rule;
2144 let rule = MD041FirstLineHeading {
2145 level: 1,
2146 front_matter_title: false,
2147 front_matter_title_pattern: None,
2148 fix_enabled: true,
2149 };
2150
2151 let content = "!!! note\n This is a note.\n";
2153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2154 let fixed = rule.fix(&ctx).unwrap();
2155 assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2156 }
2157
2158 #[test]
2159 fn test_fix_no_insert_derived_when_has_real_content() {
2160 use crate::rule::Rule;
2161 use std::path::PathBuf;
2162 let rule = MD041FirstLineHeading {
2163 level: 1,
2164 front_matter_title: false,
2165 front_matter_title_pattern: None,
2166 fix_enabled: true,
2167 };
2168
2169 let content = "!!! note\n A note.\n\nSome paragraph text.\n";
2171 let ctx = LintContext::new(
2172 content,
2173 crate::config::MarkdownFlavor::MkDocs,
2174 Some(PathBuf::from("guide.md")),
2175 );
2176 let fixed = rule.fix(&ctx).unwrap();
2177 assert_eq!(
2178 fixed, content,
2179 "Should not insert derived heading when real content is present"
2180 );
2181 }
2182
2183 #[test]
2184 fn test_derive_title_converts_kebab_case() {
2185 use std::path::PathBuf;
2186 let ctx = LintContext::new(
2187 "",
2188 crate::config::MarkdownFlavor::Standard,
2189 Some(PathBuf::from("my-setup-guide.md")),
2190 );
2191 let title = MD041FirstLineHeading::derive_title(&ctx);
2192 assert_eq!(title, Some("My Setup Guide".to_string()));
2193 }
2194
2195 #[test]
2196 fn test_derive_title_converts_underscores() {
2197 use std::path::PathBuf;
2198 let ctx = LintContext::new(
2199 "",
2200 crate::config::MarkdownFlavor::Standard,
2201 Some(PathBuf::from("api_reference.md")),
2202 );
2203 let title = MD041FirstLineHeading::derive_title(&ctx);
2204 assert_eq!(title, Some("Api Reference".to_string()));
2205 }
2206
2207 #[test]
2208 fn test_derive_title_none_without_source_file() {
2209 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2210 let title = MD041FirstLineHeading::derive_title(&ctx);
2211 assert_eq!(title, None);
2212 }
2213
2214 #[test]
2215 fn test_is_title_candidate_basic() {
2216 assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2217 assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2218 assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2219 }
2220
2221 #[test]
2222 fn test_is_title_candidate_rejects_sentence_punctuation() {
2223 assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2224 assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2225 assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2226 assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2227 assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2228 }
2229
2230 #[test]
2231 fn test_is_title_candidate_rejects_when_no_blank_after() {
2232 assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2233 }
2234
2235 #[test]
2236 fn test_is_title_candidate_rejects_long_lines() {
2237 let long = "A".repeat(81);
2238 assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2239 let ok = "A".repeat(80);
2241 assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2242 }
2243
2244 #[test]
2245 fn test_is_title_candidate_rejects_structural_markdown() {
2246 assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2247 assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2248 assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2249 assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2250 }
2251}