Skip to main content

rumdl_lib/rules/
md041_first_line_heading.rs

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/// Rule MD041: First line in file should be a top-level heading
15///
16/// See [docs/md041.md](../../docs/md041.md) for full documentation, configuration, and examples.
17
18#[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 fix_enabled: bool,
24}
25
26impl Default for MD041FirstLineHeading {
27    fn default() -> Self {
28        Self {
29            level: 1,
30            front_matter_title: true,
31            front_matter_title_pattern: None,
32            fix_enabled: false,
33        }
34    }
35}
36
37/// How to make this document compliant with MD041 (internal helper)
38enum FixPlan {
39    /// Move an existing heading to the top (after front matter), optionally releveling it.
40    MoveOrRelevel {
41        front_matter_end_idx: usize,
42        heading_idx: usize,
43        is_setext: bool,
44        current_level: usize,
45        needs_level_fix: bool,
46    },
47    /// Promote the first plain-text title line to a level-N heading, moving it to the top.
48    PromotePlainText {
49        front_matter_end_idx: usize,
50        title_line_idx: usize,
51        title_text: String,
52    },
53    /// Insert a heading derived from the source filename at the top of the document.
54    /// Used when the document contains only directive blocks and no heading or title line.
55    InsertDerived {
56        front_matter_end_idx: usize,
57        derived_title: String,
58    },
59}
60
61impl MD041FirstLineHeading {
62    pub fn new(level: usize, front_matter_title: bool) -> Self {
63        Self {
64            level,
65            front_matter_title,
66            front_matter_title_pattern: None,
67            fix_enabled: false,
68        }
69    }
70
71    pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
72        let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
73            Ok(regex) => Some(regex),
74            Err(e) => {
75                log::warn!("Invalid front_matter_title_pattern regex: {e}");
76                None
77            }
78        });
79
80        Self {
81            level,
82            front_matter_title,
83            front_matter_title_pattern,
84            fix_enabled,
85        }
86    }
87
88    fn has_front_matter_title(&self, content: &str) -> bool {
89        if !self.front_matter_title {
90            return false;
91        }
92
93        // If we have a custom pattern, use it to search front matter content
94        if let Some(ref pattern) = self.front_matter_title_pattern {
95            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
96            for line in front_matter_lines {
97                if pattern.is_match(line) {
98                    return true;
99                }
100            }
101            return false;
102        }
103
104        // Default behavior: check for "title:" field
105        FrontMatterUtils::has_front_matter_field(content, "title:")
106    }
107
108    /// Check if a line is a non-content token that should be skipped
109    fn is_non_content_line(line: &str) -> bool {
110        let trimmed = line.trim();
111
112        // Skip reference definitions
113        if trimmed.starts_with('[') && trimmed.contains("]: ") {
114            return true;
115        }
116
117        // Skip abbreviation definitions
118        if trimmed.starts_with('*') && trimmed.contains("]: ") {
119            return true;
120        }
121
122        // Skip badge/shield images - common pattern at top of READMEs
123        // Matches: ![badge](url) or [![badge](url)](url)
124        if Self::is_badge_image_line(trimmed) {
125            return true;
126        }
127
128        false
129    }
130
131    /// Find the first content line index (0-indexed) in the document.
132    ///
133    /// Skips front matter, blank lines, HTML/MDX comments, ESM blocks,
134    /// kramdown extensions, MkDocs anchors, reference definitions, and badges.
135    /// Used by both check() and fix() to ensure consistent behavior.
136    fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
137        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
138
139        let filtered = ctx
140            .filtered_lines()
141            .skip_front_matter()
142            .skip_esm_blocks()
143            .skip_html_comments()
144            .skip_mdx_comments()
145            .skip_kramdown_extension_blocks();
146
147        for filtered_line in filtered {
148            let idx = filtered_line.line_num - 1;
149            let line_info = &ctx.lines[idx];
150
151            if line_info.is_blank || line_info.is_kramdown_block_ial {
152                continue;
153            }
154
155            let line_content = filtered_line.content;
156            if is_mkdocs && is_mkdocs_anchor_line(line_content) {
157                continue;
158            }
159            if Self::is_non_content_line(line_content) {
160                continue;
161            }
162            return Some(idx);
163        }
164        None
165    }
166
167    /// Check if a line consists only of badge/shield images
168    /// Common patterns:
169    /// - `![badge](url)`
170    /// - `[![badge](url)](url)` (linked badge)
171    /// - Multiple badges on one line
172    fn is_badge_image_line(line: &str) -> bool {
173        if line.is_empty() {
174            return false;
175        }
176
177        // Must start with image syntax
178        if !line.starts_with('!') && !line.starts_with('[') {
179            return false;
180        }
181
182        // Check if line contains only image/link patterns and whitespace
183        let mut remaining = line;
184        while !remaining.is_empty() {
185            remaining = remaining.trim_start();
186            if remaining.is_empty() {
187                break;
188            }
189
190            // Linked image: [![alt](img-url)](link-url)
191            if remaining.starts_with("[![") {
192                if let Some(end) = Self::find_linked_image_end(remaining) {
193                    remaining = &remaining[end..];
194                    continue;
195                }
196                return false;
197            }
198
199            // Simple image: ![alt](url)
200            if remaining.starts_with("![") {
201                if let Some(end) = Self::find_image_end(remaining) {
202                    remaining = &remaining[end..];
203                    continue;
204                }
205                return false;
206            }
207
208            // Not an image pattern
209            return false;
210        }
211
212        true
213    }
214
215    /// Find the end of an image pattern ![alt](url)
216    fn find_image_end(s: &str) -> Option<usize> {
217        if !s.starts_with("![") {
218            return None;
219        }
220        // Find ]( after ![
221        let alt_end = s[2..].find("](")?;
222        let paren_start = 2 + alt_end + 2; // Position after ](
223        // Find closing )
224        let paren_end = s[paren_start..].find(')')?;
225        Some(paren_start + paren_end + 1)
226    }
227
228    /// Find the end of a linked image pattern [![alt](img-url)](link-url)
229    fn find_linked_image_end(s: &str) -> Option<usize> {
230        if !s.starts_with("[![") {
231            return None;
232        }
233        // Find the inner image first
234        let inner_end = Self::find_image_end(&s[1..])?;
235        let after_inner = 1 + inner_end;
236        // Should be followed by ](url)
237        if !s[after_inner..].starts_with("](") {
238            return None;
239        }
240        let link_start = after_inner + 2;
241        let link_end = s[link_start..].find(')')?;
242        Some(link_start + link_end + 1)
243    }
244
245    /// Fix a heading line to use the specified level
246    fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
247        let trimmed = line.trim_start();
248
249        // ATX-style heading (# Heading)
250        if trimmed.starts_with('#') {
251            let hashes = "#".repeat(target_level);
252            // Find where the content starts (after # and optional space)
253            let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
254            let after_hashes = &trimmed[content_start..];
255            let content = after_hashes.trim_start();
256
257            // Preserve leading whitespace from original line
258            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
259            format!("{leading_ws}{hashes} {content}")
260        } else {
261            // Setext-style heading - convert to ATX
262            // The underline would be on the next line, so we just convert the text line
263            let hashes = "#".repeat(target_level);
264            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
265            format!("{leading_ws}{hashes} {trimmed}")
266        }
267    }
268
269    /// Returns true if `text` looks like a document title rather than a body paragraph.
270    ///
271    /// Criteria:
272    /// - Non-empty and ≀80 characters
273    /// - Does not end with sentence-ending punctuation (. ? ! : ;)
274    /// - Not a Markdown structural element (heading, list, blockquote)
275    /// - Followed by a blank line or EOF (visually separated from body text)
276    fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
277        if text.is_empty() {
278            return false;
279        }
280
281        if !next_is_blank_or_eof {
282            return false;
283        }
284
285        if text.len() > 80 {
286            return false;
287        }
288
289        let last_char = text.chars().next_back().unwrap_or(' ');
290        if matches!(last_char, '.' | '?' | '!' | ':' | ';') {
291            return false;
292        }
293
294        // Already a heading or structural Markdown element
295        if text.starts_with('#')
296            || text.starts_with("- ")
297            || text.starts_with("* ")
298            || text.starts_with("+ ")
299            || text.starts_with("> ")
300        {
301            return false;
302        }
303
304        true
305    }
306
307    /// Derive a title string from the source file's stem.
308    /// Converts kebab-case and underscores to Title Case words.
309    /// Returns None when no source file is available.
310    fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
311        let path = ctx.source_file.as_ref()?;
312        let stem = path.file_stem().and_then(|s| s.to_str())?;
313
314        // For index/readme files, use the parent directory name instead.
315        // If no parent directory exists, return None β€” "Index" or "README" are not useful titles.
316        let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
317            path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
318        } else {
319            stem
320        };
321
322        let title: String = effective_stem
323            .split(['-', '_'])
324            .filter(|w| !w.is_empty())
325            .map(|word| {
326                let mut chars = word.chars();
327                match chars.next() {
328                    None => String::new(),
329                    Some(first) => {
330                        let upper: String = first.to_uppercase().collect();
331                        upper + chars.as_str()
332                    }
333                }
334            })
335            .collect::<Vec<_>>()
336            .join(" ");
337
338        if title.is_empty() { None } else { Some(title) }
339    }
340
341    /// Check if a line is an HTML heading using the centralized HTML parser
342    fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
343        // Check for single-line HTML heading using regex (fast path)
344        let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
345        if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
346            && let Some(h_level) = captures.get(1)
347            && h_level.as_str().parse::<usize>().unwrap_or(0) == level
348        {
349            return true;
350        }
351
352        // Use centralized HTML parser for multi-line headings
353        let html_tags = ctx.html_tags();
354        let target_tag = format!("h{level}");
355
356        // Find opening tag on first line
357        let opening_index = html_tags.iter().position(|tag| {
358            tag.line == first_line_idx + 1 // HtmlTag uses 1-indexed lines
359                && tag.tag_name == target_tag
360                && !tag.is_closing
361        });
362
363        let Some(open_idx) = opening_index else {
364            return false;
365        };
366
367        // Walk HTML tags to find the corresponding closing tag, allowing arbitrary nesting depth.
368        // This avoids brittle line-count heuristics and handles long headings with nested content.
369        let mut depth = 1usize;
370        for tag in html_tags.iter().skip(open_idx + 1) {
371            // Ignore tags that appear before the first heading line (possible when multiple tags share a line)
372            if tag.line <= first_line_idx + 1 {
373                continue;
374            }
375
376            if tag.tag_name == target_tag {
377                if tag.is_closing {
378                    depth -= 1;
379                    if depth == 0 {
380                        return true;
381                    }
382                } else if !tag.is_self_closing {
383                    depth += 1;
384                }
385            }
386        }
387
388        false
389    }
390
391    /// Analyze the document to determine how (if at all) it can be auto-fixed.
392    fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
393        if ctx.lines.is_empty() {
394            return None;
395        }
396
397        // Find front matter end (handles YAML, TOML, JSON, malformed)
398        let mut front_matter_end_idx = 0;
399        for line_info in &ctx.lines {
400            if line_info.in_front_matter {
401                front_matter_end_idx += 1;
402            } else {
403                break;
404            }
405        }
406
407        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
408
409        // (idx, is_setext, current_level) of the first ATX/Setext heading found
410        let mut found_heading: Option<(usize, bool, usize)> = None;
411        // First non-preamble, non-directive line that looks like a title
412        let mut first_title_candidate: Option<(usize, String)> = None;
413        // True once we see a non-preamble, non-directive line that is NOT a title candidate
414        let mut found_non_title_content = false;
415        // True when any non-directive, non-preamble line is encountered
416        let mut saw_non_directive_content = false;
417
418        'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
419            let line_content = line_info.content(ctx.content);
420            let trimmed = line_content.trim();
421
422            // Preamble: invisible/structural tokens that don't count as content
423            let is_preamble = trimmed.is_empty()
424                || line_info.in_html_comment
425                || line_info.in_mdx_comment
426                || line_info.in_html_block
427                || Self::is_non_content_line(line_content)
428                || (is_mkdocs && is_mkdocs_anchor_line(line_content))
429                || line_info.in_kramdown_extension_block
430                || line_info.is_kramdown_block_ial;
431
432            if is_preamble {
433                continue;
434            }
435
436            // Directive blocks (admonitions, content tabs, Quarto/Pandoc divs, PyMdown Blocks)
437            // are structural containers, not narrative content.
438            let is_directive_block = line_info.in_admonition
439                || line_info.in_content_tab
440                || line_info.in_pandoc_div
441                || line_info.is_div_marker
442                || line_info.in_pymdown_block;
443
444            if !is_directive_block {
445                saw_non_directive_content = true;
446            }
447
448            // ATX or Setext heading (HTML headings cannot be moved/converted)
449            if let Some(heading) = &line_info.heading {
450                let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
451                found_heading = Some((idx, is_setext, heading.level as usize));
452                break 'scan;
453            }
454
455            // Track non-heading, non-directive content for PromotePlainText detection
456            if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
457                let next_is_blank_or_eof = ctx
458                    .lines
459                    .get(idx + 1)
460                    .is_none_or(|l| l.content(ctx.content).trim().is_empty());
461
462                if Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
463                    first_title_candidate = Some((idx, trimmed.to_string()));
464                } else {
465                    found_non_title_content = true;
466                }
467            }
468        }
469
470        if let Some((h_idx, is_setext, current_level)) = found_heading {
471            // Heading exists. Can we move/relevel it?
472            // If real content or a title candidate appeared before it, the heading is not the
473            // first significant element - reordering would change document meaning.
474            if found_non_title_content || first_title_candidate.is_some() {
475                return None;
476            }
477
478            let needs_level_fix = current_level != self.level;
479            let needs_move = h_idx > front_matter_end_idx;
480
481            if needs_level_fix || needs_move {
482                return Some(FixPlan::MoveOrRelevel {
483                    front_matter_end_idx,
484                    heading_idx: h_idx,
485                    is_setext,
486                    current_level,
487                    needs_level_fix,
488                });
489            }
490            return None; // Already at the correct position and level
491        }
492
493        // No heading found. Try to create one.
494
495        if let Some((title_idx, title_text)) = first_title_candidate {
496            return Some(FixPlan::PromotePlainText {
497                front_matter_end_idx,
498                title_line_idx: title_idx,
499                title_text,
500            });
501        }
502
503        // Document has no heading and no title candidate. If it contains only directive
504        // blocks (plus preamble), we can insert a heading derived from the filename.
505        if !saw_non_directive_content && let Some(derived_title) = Self::derive_title(ctx) {
506            return Some(FixPlan::InsertDerived {
507                front_matter_end_idx,
508                derived_title,
509            });
510        }
511
512        None
513    }
514
515    /// Determine if this document can be auto-fixed.
516    fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
517        self.fix_enabled && self.analyze_for_fix(ctx).is_some()
518    }
519}
520
521impl Rule for MD041FirstLineHeading {
522    fn name(&self) -> &'static str {
523        "MD041"
524    }
525
526    fn description(&self) -> &'static str {
527        "First line in file should be a top level heading"
528    }
529
530    fn fix_capability(&self) -> FixCapability {
531        FixCapability::Unfixable
532    }
533
534    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
535        let mut warnings = Vec::new();
536
537        // Check if we should skip this file
538        if self.should_skip(ctx) {
539            return Ok(warnings);
540        }
541
542        let Some(first_line_idx) = Self::first_content_line_idx(ctx) else {
543            return Ok(warnings);
544        };
545
546        // Check if the first non-blank line is a heading of the required level
547        let first_line_info = &ctx.lines[first_line_idx];
548        let is_correct_heading = if let Some(heading) = &first_line_info.heading {
549            heading.level as usize == self.level
550        } else {
551            // Check for HTML heading (both single-line and multi-line)
552            Self::is_html_heading(ctx, first_line_idx, self.level)
553        };
554
555        if !is_correct_heading {
556            // Calculate precise character range for the entire first line
557            let first_line = first_line_idx + 1; // Convert to 1-indexed
558            let first_line_content = first_line_info.content(ctx.content);
559            let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
560
561            // Compute the actual replacement so that LSP quick-fix can apply it
562            // directly without calling fix(). For simple cases (releveling,
563            // promote-plain-text at the first content line), we use a targeted
564            // range. For complex cases (moving headings, inserting derived
565            // titles), we replace the entire document via fix().
566            let fix = if self.can_fix(ctx) {
567                self.analyze_for_fix(ctx).and_then(|plan| {
568                    let range_start = first_line_info.byte_offset;
569                    let range_end = range_start + first_line_info.byte_len;
570                    match &plan {
571                        FixPlan::MoveOrRelevel {
572                            heading_idx,
573                            current_level,
574                            needs_level_fix,
575                            is_setext,
576                            ..
577                        } if *heading_idx == first_line_idx => {
578                            // Heading is already at the correct position, just needs releveling
579                            let heading_line = ctx.lines[*heading_idx].content(ctx.content);
580                            let replacement = if *needs_level_fix || *is_setext {
581                                self.fix_heading_level(heading_line, *current_level, self.level)
582                            } else {
583                                heading_line.to_string()
584                            };
585                            Some(Fix::new(range_start..range_end, replacement))
586                        }
587                        FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
588                            let replacement = format!(
589                                "{} {}",
590                                "#".repeat(self.level),
591                                ctx.lines[*title_line_idx].content(ctx.content).trim()
592                            );
593                            Some(Fix::new(range_start..range_end, replacement))
594                        }
595                        _ => {
596                            // Complex multi-line operations (moving headings, inserting
597                            // derived titles, promoting non-first-line text): replace
598                            // the entire document via fix().
599                            self.fix(ctx)
600                                .ok()
601                                .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
602                        }
603                    }
604                })
605            } else {
606                None
607            };
608
609            warnings.push(LintWarning {
610                rule_name: Some(self.name().to_string()),
611                line: start_line,
612                column: start_col,
613                end_line,
614                end_column: end_col,
615                message: format!("First line in file should be a level {} heading", self.level),
616                severity: Severity::Warning,
617                fix,
618            });
619        }
620        Ok(warnings)
621    }
622
623    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
624        if !self.fix_enabled {
625            return Ok(ctx.content.to_string());
626        }
627
628        if self.should_skip(ctx) {
629            return Ok(ctx.content.to_string());
630        }
631
632        // Respect inline disable comments β€” use the same first-content-line
633        // logic as check() so both paths agree on which line to check.
634        let first_content_line = Self::first_content_line_idx(ctx).map_or(1, |i| i + 1);
635        if ctx.inline_config().is_rule_disabled(self.name(), first_content_line) {
636            return Ok(ctx.content.to_string());
637        }
638
639        let Some(plan) = self.analyze_for_fix(ctx) else {
640            return Ok(ctx.content.to_string());
641        };
642
643        let lines = ctx.raw_lines();
644
645        let mut result = String::new();
646        let preserve_trailing_newline = ctx.content.ends_with('\n');
647
648        match plan {
649            FixPlan::MoveOrRelevel {
650                front_matter_end_idx,
651                heading_idx,
652                is_setext,
653                current_level,
654                needs_level_fix,
655            } => {
656                let heading_line = ctx.lines[heading_idx].content(ctx.content);
657                let fixed_heading = if needs_level_fix || is_setext {
658                    self.fix_heading_level(heading_line, current_level, self.level)
659                } else {
660                    heading_line.to_string()
661                };
662
663                for line in lines.iter().take(front_matter_end_idx) {
664                    result.push_str(line);
665                    result.push('\n');
666                }
667                result.push_str(&fixed_heading);
668                result.push('\n');
669                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
670                    if idx == heading_idx {
671                        continue;
672                    }
673                    if is_setext && idx == heading_idx + 1 {
674                        continue;
675                    }
676                    result.push_str(line);
677                    result.push('\n');
678                }
679            }
680
681            FixPlan::PromotePlainText {
682                front_matter_end_idx,
683                title_line_idx,
684                title_text,
685            } => {
686                let hashes = "#".repeat(self.level);
687                let new_heading = format!("{hashes} {title_text}");
688
689                for line in lines.iter().take(front_matter_end_idx) {
690                    result.push_str(line);
691                    result.push('\n');
692                }
693                result.push_str(&new_heading);
694                result.push('\n');
695                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
696                    if idx == title_line_idx {
697                        continue;
698                    }
699                    result.push_str(line);
700                    result.push('\n');
701                }
702            }
703
704            FixPlan::InsertDerived {
705                front_matter_end_idx,
706                derived_title,
707            } => {
708                let hashes = "#".repeat(self.level);
709                let new_heading = format!("{hashes} {derived_title}");
710
711                for line in lines.iter().take(front_matter_end_idx) {
712                    result.push_str(line);
713                    result.push('\n');
714                }
715                result.push_str(&new_heading);
716                result.push('\n');
717                result.push('\n');
718                for line in lines.iter().skip(front_matter_end_idx) {
719                    result.push_str(line);
720                    result.push('\n');
721                }
722            }
723        }
724
725        if !preserve_trailing_newline && result.ends_with('\n') {
726            result.pop();
727        }
728
729        Ok(result)
730    }
731
732    /// Check if this rule should be skipped
733    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
734        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
735        // These files are composition/routing metadata, not standalone content.
736        // Example: A file containing only "{{#include ../../README.md}}" is a
737        // pointer to content, not content itself, and shouldn't need a heading.
738        let only_directives = !ctx.content.is_empty()
739            && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
740                let t = l.trim();
741                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
742                (t.starts_with("{{#") && t.ends_with("}}"))
743                        // HTML comments often accompany directives
744                        || (t.starts_with("<!--") && t.ends_with("-->"))
745            });
746
747        ctx.content.is_empty()
748            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
749            || only_directives
750    }
751
752    fn as_any(&self) -> &dyn std::any::Any {
753        self
754    }
755
756    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
757    where
758        Self: Sized,
759    {
760        // Load config using serde with kebab-case support
761        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
762
763        let use_front_matter = !md041_config.front_matter_title.is_empty();
764
765        Box::new(MD041FirstLineHeading::with_pattern(
766            md041_config.level.as_usize(),
767            use_front_matter,
768            md041_config.front_matter_title_pattern,
769            md041_config.fix,
770        ))
771    }
772
773    fn default_config_section(&self) -> Option<(String, toml::Value)> {
774        Some((
775            "MD041".to_string(),
776            toml::toml! {
777                level = 1
778                front-matter-title = "title"
779                front-matter-title-pattern = ""
780                fix = false
781            }
782            .into(),
783        ))
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use crate::lint_context::LintContext;
791
792    #[test]
793    fn test_first_line_is_heading_correct_level() {
794        let rule = MD041FirstLineHeading::default();
795
796        // First line is a level 1 heading (should pass)
797        let content = "# My Document\n\nSome content here.";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799        let result = rule.check(&ctx).unwrap();
800        assert!(
801            result.is_empty(),
802            "Expected no warnings when first line is a level 1 heading"
803        );
804    }
805
806    #[test]
807    fn test_first_line_is_heading_wrong_level() {
808        let rule = MD041FirstLineHeading::default();
809
810        // First line is a level 2 heading (should fail with level 1 requirement)
811        let content = "## My Document\n\nSome content here.";
812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813        let result = rule.check(&ctx).unwrap();
814        assert_eq!(result.len(), 1);
815        assert_eq!(result[0].line, 1);
816        assert!(result[0].message.contains("level 1 heading"));
817    }
818
819    #[test]
820    fn test_first_line_not_heading() {
821        let rule = MD041FirstLineHeading::default();
822
823        // First line is plain text (should fail)
824        let content = "This is not a heading\n\n# This is a heading";
825        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826        let result = rule.check(&ctx).unwrap();
827        assert_eq!(result.len(), 1);
828        assert_eq!(result[0].line, 1);
829        assert!(result[0].message.contains("level 1 heading"));
830    }
831
832    #[test]
833    fn test_empty_lines_before_heading() {
834        let rule = MD041FirstLineHeading::default();
835
836        // Empty lines before first heading (should pass - rule skips empty lines)
837        let content = "\n\n# My Document\n\nSome content.";
838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839        let result = rule.check(&ctx).unwrap();
840        assert!(
841            result.is_empty(),
842            "Expected no warnings when empty lines precede a valid heading"
843        );
844
845        // Empty lines before non-heading content (should fail)
846        let content = "\n\nNot a heading\n\nSome content.";
847        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848        let result = rule.check(&ctx).unwrap();
849        assert_eq!(result.len(), 1);
850        assert_eq!(result[0].line, 3); // First non-empty line
851        assert!(result[0].message.contains("level 1 heading"));
852    }
853
854    #[test]
855    fn test_front_matter_with_title() {
856        let rule = MD041FirstLineHeading::new(1, true);
857
858        // Front matter with title field (should pass)
859        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861        let result = rule.check(&ctx).unwrap();
862        assert!(
863            result.is_empty(),
864            "Expected no warnings when front matter has title field"
865        );
866    }
867
868    #[test]
869    fn test_front_matter_without_title() {
870        let rule = MD041FirstLineHeading::new(1, true);
871
872        // Front matter without title field (should fail)
873        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875        let result = rule.check(&ctx).unwrap();
876        assert_eq!(result.len(), 1);
877        assert_eq!(result[0].line, 6); // First content line after front matter
878    }
879
880    #[test]
881    fn test_front_matter_disabled() {
882        let rule = MD041FirstLineHeading::new(1, false);
883
884        // Front matter with title field but front_matter_title is false (should fail)
885        let content = "---\ntitle: My Document\n---\n\nSome content here.";
886        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887        let result = rule.check(&ctx).unwrap();
888        assert_eq!(result.len(), 1);
889        assert_eq!(result[0].line, 5); // First content line after front matter
890    }
891
892    #[test]
893    fn test_html_comments_before_heading() {
894        let rule = MD041FirstLineHeading::default();
895
896        // HTML comment before heading (should pass - comments are skipped, issue #155)
897        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let result = rule.check(&ctx).unwrap();
900        assert!(
901            result.is_empty(),
902            "HTML comments should be skipped when checking for first heading"
903        );
904    }
905
906    #[test]
907    fn test_multiline_html_comment_before_heading() {
908        let rule = MD041FirstLineHeading::default();
909
910        // Multi-line HTML comment before heading (should pass - issue #155)
911        let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913        let result = rule.check(&ctx).unwrap();
914        assert!(
915            result.is_empty(),
916            "Multi-line HTML comments should be skipped when checking for first heading"
917        );
918    }
919
920    #[test]
921    fn test_html_comment_with_blank_lines_before_heading() {
922        let rule = MD041FirstLineHeading::default();
923
924        // HTML comment with blank lines before heading (should pass - issue #155)
925        let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
926        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927        let result = rule.check(&ctx).unwrap();
928        assert!(
929            result.is_empty(),
930            "HTML comments with blank lines should be skipped when checking for first heading"
931        );
932    }
933
934    #[test]
935    fn test_html_comment_before_html_heading() {
936        let rule = MD041FirstLineHeading::default();
937
938        // HTML comment before HTML heading (should pass - issue #155)
939        let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941        let result = rule.check(&ctx).unwrap();
942        assert!(
943            result.is_empty(),
944            "HTML comments should be skipped before HTML headings"
945        );
946    }
947
948    #[test]
949    fn test_document_with_only_html_comments() {
950        let rule = MD041FirstLineHeading::default();
951
952        // Document with only HTML comments (should pass - no warnings for comment-only files)
953        let content = "<!-- This is a comment -->\n<!-- Another comment -->";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955        let result = rule.check(&ctx).unwrap();
956        assert!(
957            result.is_empty(),
958            "Documents with only HTML comments should not trigger MD041"
959        );
960    }
961
962    #[test]
963    fn test_html_comment_followed_by_non_heading() {
964        let rule = MD041FirstLineHeading::default();
965
966        // HTML comment followed by non-heading content (should still fail - issue #155)
967        let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
968        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969        let result = rule.check(&ctx).unwrap();
970        assert_eq!(
971            result.len(),
972            1,
973            "HTML comment followed by non-heading should still trigger MD041"
974        );
975        assert_eq!(
976            result[0].line, 2,
977            "Warning should be on the first non-comment, non-heading line"
978        );
979    }
980
981    #[test]
982    fn test_multiple_html_comments_before_heading() {
983        let rule = MD041FirstLineHeading::default();
984
985        // Multiple HTML comments before heading (should pass - issue #155)
986        let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
987        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988        let result = rule.check(&ctx).unwrap();
989        assert!(
990            result.is_empty(),
991            "Multiple HTML comments should all be skipped before heading"
992        );
993    }
994
995    #[test]
996    fn test_html_comment_with_wrong_level_heading() {
997        let rule = MD041FirstLineHeading::default();
998
999        // HTML comment followed by wrong-level heading (should fail - issue #155)
1000        let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1001        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002        let result = rule.check(&ctx).unwrap();
1003        assert_eq!(
1004            result.len(),
1005            1,
1006            "HTML comment followed by wrong-level heading should still trigger MD041"
1007        );
1008        assert!(
1009            result[0].message.contains("level 1 heading"),
1010            "Should require level 1 heading"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_html_comment_mixed_with_reference_definitions() {
1016        let rule = MD041FirstLineHeading::default();
1017
1018        // HTML comment mixed with reference definitions before heading (should pass - issue #155)
1019        let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021        let result = rule.check(&ctx).unwrap();
1022        assert!(
1023            result.is_empty(),
1024            "HTML comments and reference definitions should both be skipped before heading"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_html_comment_after_front_matter() {
1030        let rule = MD041FirstLineHeading::default();
1031
1032        // HTML comment after front matter, before heading (should pass - issue #155)
1033        let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035        let result = rule.check(&ctx).unwrap();
1036        assert!(
1037            result.is_empty(),
1038            "HTML comments after front matter should be skipped before heading"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_html_comment_not_at_start_should_not_affect_rule() {
1044        let rule = MD041FirstLineHeading::default();
1045
1046        // HTML comment in middle of document should not affect MD041 check
1047        let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049        let result = rule.check(&ctx).unwrap();
1050        assert!(
1051            result.is_empty(),
1052            "HTML comments in middle of document should not affect MD041 (only first content matters)"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_multiline_html_comment_followed_by_non_heading() {
1058        let rule = MD041FirstLineHeading::default();
1059
1060        // Multi-line HTML comment followed by non-heading (should still fail - issue #155)
1061        let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1062        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063        let result = rule.check(&ctx).unwrap();
1064        assert_eq!(
1065            result.len(),
1066            1,
1067            "Multi-line HTML comment followed by non-heading should still trigger MD041"
1068        );
1069        assert_eq!(
1070            result[0].line, 5,
1071            "Warning should be on the first non-comment, non-heading line"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_different_heading_levels() {
1077        // Test with level 2 requirement
1078        let rule = MD041FirstLineHeading::new(2, false);
1079
1080        let content = "## Second Level Heading\n\nContent.";
1081        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082        let result = rule.check(&ctx).unwrap();
1083        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1084
1085        // Wrong level
1086        let content = "# First Level Heading\n\nContent.";
1087        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088        let result = rule.check(&ctx).unwrap();
1089        assert_eq!(result.len(), 1);
1090        assert!(result[0].message.contains("level 2 heading"));
1091    }
1092
1093    #[test]
1094    fn test_setext_headings() {
1095        let rule = MD041FirstLineHeading::default();
1096
1097        // Setext style level 1 heading (should pass)
1098        let content = "My Document\n===========\n\nContent.";
1099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100        let result = rule.check(&ctx).unwrap();
1101        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1102
1103        // Setext style level 2 heading (should fail with level 1 requirement)
1104        let content = "My Document\n-----------\n\nContent.";
1105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106        let result = rule.check(&ctx).unwrap();
1107        assert_eq!(result.len(), 1);
1108        assert!(result[0].message.contains("level 1 heading"));
1109    }
1110
1111    #[test]
1112    fn test_empty_document() {
1113        let rule = MD041FirstLineHeading::default();
1114
1115        // Empty document (should pass - no warnings)
1116        let content = "";
1117        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118        let result = rule.check(&ctx).unwrap();
1119        assert!(result.is_empty(), "Expected no warnings for empty document");
1120    }
1121
1122    #[test]
1123    fn test_whitespace_only_document() {
1124        let rule = MD041FirstLineHeading::default();
1125
1126        // Document with only whitespace (should pass - no warnings)
1127        let content = "   \n\n   \t\n";
1128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129        let result = rule.check(&ctx).unwrap();
1130        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1131    }
1132
1133    #[test]
1134    fn test_front_matter_then_whitespace() {
1135        let rule = MD041FirstLineHeading::default();
1136
1137        // Front matter followed by only whitespace (should pass - no warnings)
1138        let content = "---\ntitle: Test\n---\n\n   \n\n";
1139        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1140        let result = rule.check(&ctx).unwrap();
1141        assert!(
1142            result.is_empty(),
1143            "Expected no warnings when no content after front matter"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_multiple_front_matter_types() {
1149        let rule = MD041FirstLineHeading::new(1, true);
1150
1151        // TOML front matter with title (should pass - title satisfies heading requirement)
1152        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1153        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154        let result = rule.check(&ctx).unwrap();
1155        assert!(
1156            result.is_empty(),
1157            "Expected no warnings for TOML front matter with title"
1158        );
1159
1160        // JSON front matter with title (should pass)
1161        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163        let result = rule.check(&ctx).unwrap();
1164        assert!(
1165            result.is_empty(),
1166            "Expected no warnings for JSON front matter with title"
1167        );
1168
1169        // YAML front matter with title field (standard case)
1170        let content = "---\ntitle: My Document\n---\n\nContent.";
1171        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172        let result = rule.check(&ctx).unwrap();
1173        assert!(
1174            result.is_empty(),
1175            "Expected no warnings for YAML front matter with title"
1176        );
1177    }
1178
1179    #[test]
1180    fn test_toml_front_matter_with_heading() {
1181        let rule = MD041FirstLineHeading::default();
1182
1183        // TOML front matter followed by correct heading (should pass)
1184        let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1185        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186        let result = rule.check(&ctx).unwrap();
1187        assert!(
1188            result.is_empty(),
1189            "Expected no warnings when heading follows TOML front matter"
1190        );
1191    }
1192
1193    #[test]
1194    fn test_toml_front_matter_without_title_no_heading() {
1195        let rule = MD041FirstLineHeading::new(1, true);
1196
1197        // TOML front matter without title, no heading (should warn)
1198        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200        let result = rule.check(&ctx).unwrap();
1201        assert_eq!(result.len(), 1);
1202        assert_eq!(result[0].line, 6);
1203    }
1204
1205    #[test]
1206    fn test_toml_front_matter_level_2_heading() {
1207        // Reproduces the exact scenario from issue #427
1208        let rule = MD041FirstLineHeading::new(2, true);
1209
1210        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212        let result = rule.check(&ctx).unwrap();
1213        assert!(
1214            result.is_empty(),
1215            "Issue #427: TOML front matter with title and correct heading level should not warn"
1216        );
1217    }
1218
1219    #[test]
1220    fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1221        // Reproduces the exact config shape from issue #427
1222        let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1223
1224        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1225        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226        let result = rule.check(&ctx).unwrap();
1227        assert!(
1228            result.is_empty(),
1229            "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_json_front_matter_with_heading() {
1235        let rule = MD041FirstLineHeading::default();
1236
1237        // JSON front matter followed by correct heading
1238        let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1239        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240        let result = rule.check(&ctx).unwrap();
1241        assert!(
1242            result.is_empty(),
1243            "Expected no warnings when heading follows JSON front matter"
1244        );
1245    }
1246
1247    #[test]
1248    fn test_malformed_front_matter() {
1249        let rule = MD041FirstLineHeading::new(1, true);
1250
1251        // Malformed front matter with title
1252        let content = "- --\ntitle: My Document\n- --\n\nContent.";
1253        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254        let result = rule.check(&ctx).unwrap();
1255        assert!(
1256            result.is_empty(),
1257            "Expected no warnings for malformed front matter with title"
1258        );
1259    }
1260
1261    #[test]
1262    fn test_front_matter_with_heading() {
1263        let rule = MD041FirstLineHeading::default();
1264
1265        // Front matter without title field followed by correct heading
1266        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268        let result = rule.check(&ctx).unwrap();
1269        assert!(
1270            result.is_empty(),
1271            "Expected no warnings when first line after front matter is correct heading"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_no_fix_suggestion() {
1277        let rule = MD041FirstLineHeading::default();
1278
1279        // Check that NO fix suggestion is provided (MD041 is now detection-only)
1280        let content = "Not a heading\n\nContent.";
1281        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282        let result = rule.check(&ctx).unwrap();
1283        assert_eq!(result.len(), 1);
1284        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1285    }
1286
1287    #[test]
1288    fn test_complex_document_structure() {
1289        let rule = MD041FirstLineHeading::default();
1290
1291        // Complex document with various elements - HTML comment should be skipped (issue #155)
1292        let content =
1293            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1294        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295        let result = rule.check(&ctx).unwrap();
1296        assert!(
1297            result.is_empty(),
1298            "HTML comments should be skipped, so first heading after comment should be valid"
1299        );
1300    }
1301
1302    #[test]
1303    fn test_heading_with_special_characters() {
1304        let rule = MD041FirstLineHeading::default();
1305
1306        // Heading with special characters and formatting
1307        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let result = rule.check(&ctx).unwrap();
1310        assert!(
1311            result.is_empty(),
1312            "Expected no warnings for heading with inline formatting"
1313        );
1314    }
1315
1316    #[test]
1317    fn test_level_configuration() {
1318        // Test various level configurations
1319        for level in 1..=6 {
1320            let rule = MD041FirstLineHeading::new(level, false);
1321
1322            // Correct level
1323            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1324            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1325            let result = rule.check(&ctx).unwrap();
1326            assert!(
1327                result.is_empty(),
1328                "Expected no warnings for correct level {level} heading"
1329            );
1330
1331            // Wrong level
1332            let wrong_level = if level == 1 { 2 } else { 1 };
1333            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1334            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1335            let result = rule.check(&ctx).unwrap();
1336            assert_eq!(result.len(), 1);
1337            assert!(result[0].message.contains(&format!("level {level} heading")));
1338        }
1339    }
1340
1341    #[test]
1342    fn test_issue_152_multiline_html_heading() {
1343        let rule = MD041FirstLineHeading::default();
1344
1345        // Multi-line HTML h1 heading (should pass - issue #152)
1346        let content = "<h1>\nSome text\n</h1>";
1347        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348        let result = rule.check(&ctx).unwrap();
1349        assert!(
1350            result.is_empty(),
1351            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1352        );
1353    }
1354
1355    #[test]
1356    fn test_multiline_html_heading_with_attributes() {
1357        let rule = MD041FirstLineHeading::default();
1358
1359        // Multi-line HTML heading with attributes
1360        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362        let result = rule.check(&ctx).unwrap();
1363        assert!(
1364            result.is_empty(),
1365            "Multi-line HTML heading with attributes should be recognized"
1366        );
1367    }
1368
1369    #[test]
1370    fn test_multiline_html_heading_wrong_level() {
1371        let rule = MD041FirstLineHeading::default();
1372
1373        // Multi-line HTML h2 heading (should fail with level 1 requirement)
1374        let content = "<h2>\nSome text\n</h2>";
1375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376        let result = rule.check(&ctx).unwrap();
1377        assert_eq!(result.len(), 1);
1378        assert!(result[0].message.contains("level 1 heading"));
1379    }
1380
1381    #[test]
1382    fn test_multiline_html_heading_with_content_after() {
1383        let rule = MD041FirstLineHeading::default();
1384
1385        // Multi-line HTML heading followed by content
1386        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1387        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388        let result = rule.check(&ctx).unwrap();
1389        assert!(
1390            result.is_empty(),
1391            "Multi-line HTML heading followed by content should be valid"
1392        );
1393    }
1394
1395    #[test]
1396    fn test_multiline_html_heading_incomplete() {
1397        let rule = MD041FirstLineHeading::default();
1398
1399        // Incomplete multi-line HTML heading (missing closing tag)
1400        let content = "<h1>\nSome text\n\nMore content without closing tag";
1401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1402        let result = rule.check(&ctx).unwrap();
1403        assert_eq!(result.len(), 1);
1404        assert!(result[0].message.contains("level 1 heading"));
1405    }
1406
1407    #[test]
1408    fn test_singleline_html_heading_still_works() {
1409        let rule = MD041FirstLineHeading::default();
1410
1411        // Single-line HTML heading should still work
1412        let content = "<h1>My Document</h1>\n\nContent.";
1413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414        let result = rule.check(&ctx).unwrap();
1415        assert!(
1416            result.is_empty(),
1417            "Single-line HTML headings should still be recognized"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_multiline_html_heading_with_nested_tags() {
1423        let rule = MD041FirstLineHeading::default();
1424
1425        // Multi-line HTML heading with nested tags
1426        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428        let result = rule.check(&ctx).unwrap();
1429        assert!(
1430            result.is_empty(),
1431            "Multi-line HTML heading with nested tags should be recognized"
1432        );
1433    }
1434
1435    #[test]
1436    fn test_multiline_html_heading_various_levels() {
1437        // Test multi-line headings at different levels
1438        for level in 1..=6 {
1439            let rule = MD041FirstLineHeading::new(level, false);
1440
1441            // Correct level multi-line
1442            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1443            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1444            let result = rule.check(&ctx).unwrap();
1445            assert!(
1446                result.is_empty(),
1447                "Multi-line HTML heading at level {level} should be recognized"
1448            );
1449
1450            // Wrong level multi-line
1451            let wrong_level = if level == 1 { 2 } else { 1 };
1452            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1453            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1454            let result = rule.check(&ctx).unwrap();
1455            assert_eq!(result.len(), 1);
1456            assert!(result[0].message.contains(&format!("level {level} heading")));
1457        }
1458    }
1459
1460    #[test]
1461    fn test_issue_152_nested_heading_spans_many_lines() {
1462        let rule = MD041FirstLineHeading::default();
1463
1464        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>";
1465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466        let result = rule.check(&ctx).unwrap();
1467        assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1468    }
1469
1470    #[test]
1471    fn test_issue_152_picture_tag_heading() {
1472        let rule = MD041FirstLineHeading::default();
1473
1474        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>";
1475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476        let result = rule.check(&ctx).unwrap();
1477        assert!(
1478            result.is_empty(),
1479            "Picture tag inside multi-line HTML heading should be recognized"
1480        );
1481    }
1482
1483    #[test]
1484    fn test_badge_images_before_heading() {
1485        let rule = MD041FirstLineHeading::default();
1486
1487        // Single badge before heading
1488        let content = "![badge](https://img.shields.io/badge/test-passing-green)\n\n# My Project";
1489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490        let result = rule.check(&ctx).unwrap();
1491        assert!(result.is_empty(), "Badge image should be skipped");
1492
1493        // Multiple badges on one line
1494        let content = "![badge1](url1) ![badge2](url2)\n\n# My Project";
1495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496        let result = rule.check(&ctx).unwrap();
1497        assert!(result.is_empty(), "Multiple badges should be skipped");
1498
1499        // Linked badge (clickable)
1500        let content = "[![badge](https://img.shields.io/badge/test-pass-green)](https://example.com)\n\n# My Project";
1501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502        let result = rule.check(&ctx).unwrap();
1503        assert!(result.is_empty(), "Linked badge should be skipped");
1504    }
1505
1506    #[test]
1507    fn test_multiple_badge_lines_before_heading() {
1508        let rule = MD041FirstLineHeading::default();
1509
1510        // Multiple lines of badges
1511        let content = "[![Crates.io](https://img.shields.io/crates/v/example)](https://crates.io)\n[![docs.rs](https://img.shields.io/docsrs/example)](https://docs.rs)\n\n# My Project";
1512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1513        let result = rule.check(&ctx).unwrap();
1514        assert!(result.is_empty(), "Multiple badge lines should be skipped");
1515    }
1516
1517    #[test]
1518    fn test_badges_without_heading_still_warns() {
1519        let rule = MD041FirstLineHeading::default();
1520
1521        // Badges followed by paragraph (not heading)
1522        let content = "![badge](url)\n\nThis is not a heading.";
1523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524        let result = rule.check(&ctx).unwrap();
1525        assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1526    }
1527
1528    #[test]
1529    fn test_mixed_content_not_badge_line() {
1530        let rule = MD041FirstLineHeading::default();
1531
1532        // Image with text is not a badge line
1533        let content = "![badge](url) Some text here\n\n# Heading";
1534        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535        let result = rule.check(&ctx).unwrap();
1536        assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1537    }
1538
1539    #[test]
1540    fn test_is_badge_image_line_unit() {
1541        // Unit tests for is_badge_image_line
1542        assert!(MD041FirstLineHeading::is_badge_image_line("![badge](url)"));
1543        assert!(MD041FirstLineHeading::is_badge_image_line("[![badge](img)](link)"));
1544        assert!(MD041FirstLineHeading::is_badge_image_line("![a](b) ![c](d)"));
1545        assert!(MD041FirstLineHeading::is_badge_image_line("[![a](b)](c) [![d](e)](f)"));
1546
1547        // Not badge lines
1548        assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1549        assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1550        assert!(!MD041FirstLineHeading::is_badge_image_line("![badge](url) text"));
1551        assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1552    }
1553
1554    // Integration tests for MkDocs anchor line detection (issue #365)
1555    // Unit tests for is_mkdocs_anchor_line are in utils/mkdocs_attr_list.rs
1556
1557    #[test]
1558    fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1559        let rule = MD041FirstLineHeading::default();
1560
1561        // MkDocs anchor line before heading in MkDocs flavor (should pass)
1562        let content = "[](){ #example }\n# Title";
1563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1564        let result = rule.check(&ctx).unwrap();
1565        assert!(
1566            result.is_empty(),
1567            "MkDocs anchor line should be skipped in MkDocs flavor"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1573        let rule = MD041FirstLineHeading::default();
1574
1575        // MkDocs anchor line before heading in Standard flavor (should fail)
1576        let content = "[](){ #example }\n# Title";
1577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578        let result = rule.check(&ctx).unwrap();
1579        assert_eq!(
1580            result.len(),
1581            1,
1582            "MkDocs anchor line should NOT be skipped in Standard flavor"
1583        );
1584    }
1585
1586    #[test]
1587    fn test_multiple_mkdocs_anchors_before_heading() {
1588        let rule = MD041FirstLineHeading::default();
1589
1590        // Multiple MkDocs anchor lines before heading in MkDocs flavor
1591        let content = "[](){ #first }\n[](){ #second }\n# Title";
1592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1593        let result = rule.check(&ctx).unwrap();
1594        assert!(
1595            result.is_empty(),
1596            "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1597        );
1598    }
1599
1600    #[test]
1601    fn test_mkdocs_anchor_with_front_matter() {
1602        let rule = MD041FirstLineHeading::default();
1603
1604        // MkDocs anchor after front matter
1605        let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1607        let result = rule.check(&ctx).unwrap();
1608        assert!(
1609            result.is_empty(),
1610            "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1611        );
1612    }
1613
1614    #[test]
1615    fn test_mkdocs_anchor_kramdown_style() {
1616        let rule = MD041FirstLineHeading::default();
1617
1618        // Kramdown-style with colon
1619        let content = "[](){: #anchor }\n# Title";
1620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1621        let result = rule.check(&ctx).unwrap();
1622        assert!(
1623            result.is_empty(),
1624            "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1625        );
1626    }
1627
1628    #[test]
1629    fn test_mkdocs_anchor_without_heading_still_warns() {
1630        let rule = MD041FirstLineHeading::default();
1631
1632        // MkDocs anchor followed by non-heading content
1633        let content = "[](){ #anchor }\nThis is not a heading.";
1634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1635        let result = rule.check(&ctx).unwrap();
1636        assert_eq!(
1637            result.len(),
1638            1,
1639            "MkDocs anchor followed by non-heading should still trigger MD041"
1640        );
1641    }
1642
1643    #[test]
1644    fn test_mkdocs_anchor_with_html_comment() {
1645        let rule = MD041FirstLineHeading::default();
1646
1647        // MkDocs anchor combined with HTML comment before heading
1648        let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1649        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1650        let result = rule.check(&ctx).unwrap();
1651        assert!(
1652            result.is_empty(),
1653            "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1654        );
1655    }
1656
1657    // Tests for auto-fix functionality (issue #359)
1658
1659    #[test]
1660    fn test_fix_disabled_by_default() {
1661        use crate::rule::Rule;
1662        let rule = MD041FirstLineHeading::default();
1663
1664        // Fix should not change content when disabled
1665        let content = "## Wrong Level\n\nContent.";
1666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667        let fixed = rule.fix(&ctx).unwrap();
1668        assert_eq!(fixed, content, "Fix should not change content when disabled");
1669    }
1670
1671    #[test]
1672    fn test_fix_wrong_heading_level() {
1673        use crate::rule::Rule;
1674        let rule = MD041FirstLineHeading {
1675            level: 1,
1676            front_matter_title: false,
1677            front_matter_title_pattern: None,
1678            fix_enabled: true,
1679        };
1680
1681        // ## should become #
1682        let content = "## Wrong Level\n\nContent.\n";
1683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684        let fixed = rule.fix(&ctx).unwrap();
1685        assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1686    }
1687
1688    #[test]
1689    fn test_fix_heading_after_preamble() {
1690        use crate::rule::Rule;
1691        let rule = MD041FirstLineHeading {
1692            level: 1,
1693            front_matter_title: false,
1694            front_matter_title_pattern: None,
1695            fix_enabled: true,
1696        };
1697
1698        // Heading after blank lines should be moved up
1699        let content = "\n\n# Title\n\nContent.\n";
1700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701        let fixed = rule.fix(&ctx).unwrap();
1702        assert!(
1703            fixed.starts_with("# Title\n"),
1704            "Heading should be moved to first line, got: {fixed}"
1705        );
1706    }
1707
1708    #[test]
1709    fn test_fix_heading_after_html_comment() {
1710        use crate::rule::Rule;
1711        let rule = MD041FirstLineHeading {
1712            level: 1,
1713            front_matter_title: false,
1714            front_matter_title_pattern: None,
1715            fix_enabled: true,
1716        };
1717
1718        // Heading after HTML comment should be moved up
1719        let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1720        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721        let fixed = rule.fix(&ctx).unwrap();
1722        assert!(
1723            fixed.starts_with("# Title\n"),
1724            "Heading should be moved above comment, got: {fixed}"
1725        );
1726    }
1727
1728    #[test]
1729    fn test_fix_heading_level_and_move() {
1730        use crate::rule::Rule;
1731        let rule = MD041FirstLineHeading {
1732            level: 1,
1733            front_matter_title: false,
1734            front_matter_title_pattern: None,
1735            fix_enabled: true,
1736        };
1737
1738        // Heading with wrong level after preamble should be fixed and moved
1739        let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741        let fixed = rule.fix(&ctx).unwrap();
1742        assert!(
1743            fixed.starts_with("# Wrong Level\n"),
1744            "Heading should be fixed and moved, got: {fixed}"
1745        );
1746    }
1747
1748    #[test]
1749    fn test_fix_with_front_matter() {
1750        use crate::rule::Rule;
1751        let rule = MD041FirstLineHeading {
1752            level: 1,
1753            front_matter_title: false,
1754            front_matter_title_pattern: None,
1755            fix_enabled: true,
1756        };
1757
1758        // Heading after front matter and preamble
1759        let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1760        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761        let fixed = rule.fix(&ctx).unwrap();
1762        assert!(
1763            fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1764            "Heading should be right after front matter, got: {fixed}"
1765        );
1766    }
1767
1768    #[test]
1769    fn test_fix_with_toml_front_matter() {
1770        use crate::rule::Rule;
1771        let rule = MD041FirstLineHeading {
1772            level: 1,
1773            front_matter_title: false,
1774            front_matter_title_pattern: None,
1775            fix_enabled: true,
1776        };
1777
1778        // Heading after TOML front matter and preamble
1779        let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1780        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1781        let fixed = rule.fix(&ctx).unwrap();
1782        assert!(
1783            fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1784            "Heading should be right after TOML front matter, got: {fixed}"
1785        );
1786    }
1787
1788    #[test]
1789    fn test_fix_cannot_fix_no_heading() {
1790        use crate::rule::Rule;
1791        let rule = MD041FirstLineHeading {
1792            level: 1,
1793            front_matter_title: false,
1794            front_matter_title_pattern: None,
1795            fix_enabled: true,
1796        };
1797
1798        // No heading in document - cannot fix
1799        let content = "Just some text.\n\nMore text.\n";
1800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1801        let fixed = rule.fix(&ctx).unwrap();
1802        assert_eq!(fixed, content, "Should not change content when no heading exists");
1803    }
1804
1805    #[test]
1806    fn test_fix_cannot_fix_content_before_heading() {
1807        use crate::rule::Rule;
1808        let rule = MD041FirstLineHeading {
1809            level: 1,
1810            front_matter_title: false,
1811            front_matter_title_pattern: None,
1812            fix_enabled: true,
1813        };
1814
1815        // Real content before heading - cannot safely fix
1816        let content = "Some intro text.\n\n# Title\n\nContent.\n";
1817        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818        let fixed = rule.fix(&ctx).unwrap();
1819        assert_eq!(
1820            fixed, content,
1821            "Should not change content when real content exists before heading"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_fix_already_correct() {
1827        use crate::rule::Rule;
1828        let rule = MD041FirstLineHeading {
1829            level: 1,
1830            front_matter_title: false,
1831            front_matter_title_pattern: None,
1832            fix_enabled: true,
1833        };
1834
1835        // Already correct - no changes needed
1836        let content = "# Title\n\nContent.\n";
1837        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1838        let fixed = rule.fix(&ctx).unwrap();
1839        assert_eq!(fixed, content, "Should not change already correct content");
1840    }
1841
1842    #[test]
1843    fn test_fix_setext_heading_removes_underline() {
1844        use crate::rule::Rule;
1845        let rule = MD041FirstLineHeading {
1846            level: 1,
1847            front_matter_title: false,
1848            front_matter_title_pattern: None,
1849            fix_enabled: true,
1850        };
1851
1852        // Setext heading (level 2 with --- underline)
1853        let content = "Wrong Level\n-----------\n\nContent.\n";
1854        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855        let fixed = rule.fix(&ctx).unwrap();
1856        assert_eq!(
1857            fixed, "# Wrong Level\n\nContent.\n",
1858            "Setext heading should be converted to ATX and underline removed"
1859        );
1860    }
1861
1862    #[test]
1863    fn test_fix_setext_h1_heading() {
1864        use crate::rule::Rule;
1865        let rule = MD041FirstLineHeading {
1866            level: 1,
1867            front_matter_title: false,
1868            front_matter_title_pattern: None,
1869            fix_enabled: true,
1870        };
1871
1872        // Setext h1 heading (=== underline) after preamble - needs move but not level fix
1873        let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875        let fixed = rule.fix(&ctx).unwrap();
1876        assert_eq!(
1877            fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1878            "Setext h1 should be moved and converted to ATX"
1879        );
1880    }
1881
1882    #[test]
1883    fn test_html_heading_not_claimed_fixable() {
1884        use crate::rule::Rule;
1885        let rule = MD041FirstLineHeading {
1886            level: 1,
1887            front_matter_title: false,
1888            front_matter_title_pattern: None,
1889            fix_enabled: true,
1890        };
1891
1892        // HTML heading - should NOT be claimed as fixable (we can't convert HTML to ATX)
1893        let content = "<h2>Title</h2>\n\nContent.\n";
1894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895        let warnings = rule.check(&ctx).unwrap();
1896        assert_eq!(warnings.len(), 1);
1897        assert!(
1898            warnings[0].fix.is_none(),
1899            "HTML heading should not be claimed as fixable"
1900        );
1901    }
1902
1903    #[test]
1904    fn test_no_heading_not_claimed_fixable() {
1905        use crate::rule::Rule;
1906        let rule = MD041FirstLineHeading {
1907            level: 1,
1908            front_matter_title: false,
1909            front_matter_title_pattern: None,
1910            fix_enabled: true,
1911        };
1912
1913        // No heading in document - should NOT be claimed as fixable
1914        let content = "Just some text.\n\nMore text.\n";
1915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1916        let warnings = rule.check(&ctx).unwrap();
1917        assert_eq!(warnings.len(), 1);
1918        assert!(
1919            warnings[0].fix.is_none(),
1920            "Document without heading should not be claimed as fixable"
1921        );
1922    }
1923
1924    #[test]
1925    fn test_content_before_heading_not_claimed_fixable() {
1926        use crate::rule::Rule;
1927        let rule = MD041FirstLineHeading {
1928            level: 1,
1929            front_matter_title: false,
1930            front_matter_title_pattern: None,
1931            fix_enabled: true,
1932        };
1933
1934        // Content before heading - should NOT be claimed as fixable
1935        let content = "Intro text.\n\n## Heading\n\nMore.\n";
1936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937        let warnings = rule.check(&ctx).unwrap();
1938        assert_eq!(warnings.len(), 1);
1939        assert!(
1940            warnings[0].fix.is_none(),
1941            "Document with content before heading should not be claimed as fixable"
1942        );
1943    }
1944
1945    // ── Phase 1 (Case C): HTML blocks treated as preamble ──────────────────────
1946
1947    #[test]
1948    fn test_fix_html_block_before_heading_is_now_fixable() {
1949        use crate::rule::Rule;
1950        let rule = MD041FirstLineHeading {
1951            level: 1,
1952            front_matter_title: false,
1953            front_matter_title_pattern: None,
1954            fix_enabled: true,
1955        };
1956
1957        // HTML block (badges div) before the real heading – was unfixable before Phase 1
1958        let content = "<div>\n  Some HTML\n</div>\n\n# My Document\n\nContent.\n";
1959        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960
1961        let warnings = rule.check(&ctx).unwrap();
1962        assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
1963        assert!(
1964            warnings[0].fix.is_some(),
1965            "Should be fixable: heading exists after HTML block preamble"
1966        );
1967
1968        let fixed = rule.fix(&ctx).unwrap();
1969        assert!(
1970            fixed.starts_with("# My Document\n"),
1971            "Heading should be moved to the top, got: {fixed}"
1972        );
1973    }
1974
1975    #[test]
1976    fn test_fix_html_block_wrong_level_before_heading() {
1977        use crate::rule::Rule;
1978        let rule = MD041FirstLineHeading {
1979            level: 1,
1980            front_matter_title: false,
1981            front_matter_title_pattern: None,
1982            fix_enabled: true,
1983        };
1984
1985        let content = "<div>\n  badge\n</div>\n\n## Wrong Level\n\nContent.\n";
1986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987        let fixed = rule.fix(&ctx).unwrap();
1988        assert!(
1989            fixed.starts_with("# Wrong Level\n"),
1990            "Heading should be fixed to level 1 and moved to top, got: {fixed}"
1991        );
1992    }
1993
1994    // ── Phase 2 (Case A): PromotePlainText ──────────────────────────────────────
1995
1996    #[test]
1997    fn test_fix_promote_plain_text_title() {
1998        use crate::rule::Rule;
1999        let rule = MD041FirstLineHeading {
2000            level: 1,
2001            front_matter_title: false,
2002            front_matter_title_pattern: None,
2003            fix_enabled: true,
2004        };
2005
2006        let content = "My Project\n\nSome content.\n";
2007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2008
2009        let warnings = rule.check(&ctx).unwrap();
2010        assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2011        assert!(
2012            warnings[0].fix.is_some(),
2013            "Should be fixable: first line is a title candidate"
2014        );
2015
2016        let fixed = rule.fix(&ctx).unwrap();
2017        assert_eq!(
2018            fixed, "# My Project\n\nSome content.\n",
2019            "Title line should be promoted to heading"
2020        );
2021    }
2022
2023    #[test]
2024    fn test_fix_promote_plain_text_title_with_front_matter() {
2025        use crate::rule::Rule;
2026        let rule = MD041FirstLineHeading {
2027            level: 1,
2028            front_matter_title: false,
2029            front_matter_title_pattern: None,
2030            fix_enabled: true,
2031        };
2032
2033        let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2035        let fixed = rule.fix(&ctx).unwrap();
2036        assert!(
2037            fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2038            "Title should be promoted and placed right after front matter, got: {fixed}"
2039        );
2040    }
2041
2042    #[test]
2043    fn test_fix_no_promote_ends_with_period() {
2044        use crate::rule::Rule;
2045        let rule = MD041FirstLineHeading {
2046            level: 1,
2047            front_matter_title: false,
2048            front_matter_title_pattern: None,
2049            fix_enabled: true,
2050        };
2051
2052        // Sentence-ending punctuation β†’ NOT a title candidate
2053        let content = "This is a sentence.\n\nContent.\n";
2054        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2055        let fixed = rule.fix(&ctx).unwrap();
2056        assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2057
2058        let warnings = rule.check(&ctx).unwrap();
2059        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2060    }
2061
2062    #[test]
2063    fn test_fix_no_promote_ends_with_colon() {
2064        use crate::rule::Rule;
2065        let rule = MD041FirstLineHeading {
2066            level: 1,
2067            front_matter_title: false,
2068            front_matter_title_pattern: None,
2069            fix_enabled: true,
2070        };
2071
2072        let content = "Note:\n\nContent.\n";
2073        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2074        let fixed = rule.fix(&ctx).unwrap();
2075        assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2076    }
2077
2078    #[test]
2079    fn test_fix_no_promote_if_too_long() {
2080        use crate::rule::Rule;
2081        let rule = MD041FirstLineHeading {
2082            level: 1,
2083            front_matter_title: false,
2084            front_matter_title_pattern: None,
2085            fix_enabled: true,
2086        };
2087
2088        // >80 chars β†’ not a title candidate
2089        let long_line = "A".repeat(81);
2090        let content = format!("{long_line}\n\nContent.\n");
2091        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2092        let fixed = rule.fix(&ctx).unwrap();
2093        assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2094    }
2095
2096    #[test]
2097    fn test_fix_no_promote_if_no_blank_after() {
2098        use crate::rule::Rule;
2099        let rule = MD041FirstLineHeading {
2100            level: 1,
2101            front_matter_title: false,
2102            front_matter_title_pattern: None,
2103            fix_enabled: true,
2104        };
2105
2106        // No blank line after potential title β†’ NOT a title candidate
2107        let content = "My Project\nImmediately continues.\n\nContent.\n";
2108        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2109        let fixed = rule.fix(&ctx).unwrap();
2110        assert_eq!(fixed, content, "Line without following blank should not be promoted");
2111    }
2112
2113    #[test]
2114    fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2115        use crate::rule::Rule;
2116        let rule = MD041FirstLineHeading {
2117            level: 1,
2118            front_matter_title: false,
2119            front_matter_title_pattern: None,
2120            fix_enabled: true,
2121        };
2122
2123        // Title candidate exists but so does a heading later β†’ can't safely fix
2124        // (the title candidate is content before the heading)
2125        let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127        let fixed = rule.fix(&ctx).unwrap();
2128        assert_eq!(
2129            fixed, content,
2130            "Should not fix when title candidate exists before a heading"
2131        );
2132
2133        let warnings = rule.check(&ctx).unwrap();
2134        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2135    }
2136
2137    #[test]
2138    fn test_fix_promote_title_at_eof_no_trailing_newline() {
2139        use crate::rule::Rule;
2140        let rule = MD041FirstLineHeading {
2141            level: 1,
2142            front_matter_title: false,
2143            front_matter_title_pattern: None,
2144            fix_enabled: true,
2145        };
2146
2147        // Single title line at EOF with no trailing newline
2148        let content = "My Project";
2149        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2150        let fixed = rule.fix(&ctx).unwrap();
2151        assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2152    }
2153
2154    // ── Phase 3 (Case B): InsertDerived ─────────────────────────────────────────
2155
2156    #[test]
2157    fn test_fix_insert_derived_directive_only_document() {
2158        use crate::rule::Rule;
2159        use std::path::PathBuf;
2160        let rule = MD041FirstLineHeading {
2161            level: 1,
2162            front_matter_title: false,
2163            front_matter_title_pattern: None,
2164            fix_enabled: true,
2165        };
2166
2167        // Document with only a note admonition and no heading
2168        // (LintContext constructed with a source file path for title derivation)
2169        let content = "!!! note\n    This is a note.\n";
2170        let ctx = LintContext::new(
2171            content,
2172            crate::config::MarkdownFlavor::MkDocs,
2173            Some(PathBuf::from("setup-guide.md")),
2174        );
2175
2176        let can_fix = rule.can_fix(&ctx);
2177        assert!(can_fix, "Directive-only document with source file should be fixable");
2178
2179        let fixed = rule.fix(&ctx).unwrap();
2180        assert!(
2181            fixed.starts_with("# Setup Guide\n"),
2182            "Should insert derived heading, got: {fixed}"
2183        );
2184    }
2185
2186    #[test]
2187    fn test_fix_no_insert_derived_without_source_file() {
2188        use crate::rule::Rule;
2189        let rule = MD041FirstLineHeading {
2190            level: 1,
2191            front_matter_title: false,
2192            front_matter_title_pattern: None,
2193            fix_enabled: true,
2194        };
2195
2196        // No source_file β†’ derive_title returns None β†’ InsertDerived unavailable
2197        let content = "!!! note\n    This is a note.\n";
2198        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2199        let fixed = rule.fix(&ctx).unwrap();
2200        assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2201    }
2202
2203    #[test]
2204    fn test_fix_no_insert_derived_when_has_real_content() {
2205        use crate::rule::Rule;
2206        use std::path::PathBuf;
2207        let rule = MD041FirstLineHeading {
2208            level: 1,
2209            front_matter_title: false,
2210            front_matter_title_pattern: None,
2211            fix_enabled: true,
2212        };
2213
2214        // Document has real paragraph content in addition to directive blocks
2215        let content = "!!! note\n    A note.\n\nSome paragraph text.\n";
2216        let ctx = LintContext::new(
2217            content,
2218            crate::config::MarkdownFlavor::MkDocs,
2219            Some(PathBuf::from("guide.md")),
2220        );
2221        let fixed = rule.fix(&ctx).unwrap();
2222        assert_eq!(
2223            fixed, content,
2224            "Should not insert derived heading when real content is present"
2225        );
2226    }
2227
2228    #[test]
2229    fn test_derive_title_converts_kebab_case() {
2230        use std::path::PathBuf;
2231        let ctx = LintContext::new(
2232            "",
2233            crate::config::MarkdownFlavor::Standard,
2234            Some(PathBuf::from("my-setup-guide.md")),
2235        );
2236        let title = MD041FirstLineHeading::derive_title(&ctx);
2237        assert_eq!(title, Some("My Setup Guide".to_string()));
2238    }
2239
2240    #[test]
2241    fn test_derive_title_converts_underscores() {
2242        use std::path::PathBuf;
2243        let ctx = LintContext::new(
2244            "",
2245            crate::config::MarkdownFlavor::Standard,
2246            Some(PathBuf::from("api_reference.md")),
2247        );
2248        let title = MD041FirstLineHeading::derive_title(&ctx);
2249        assert_eq!(title, Some("Api Reference".to_string()));
2250    }
2251
2252    #[test]
2253    fn test_derive_title_none_without_source_file() {
2254        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2255        let title = MD041FirstLineHeading::derive_title(&ctx);
2256        assert_eq!(title, None);
2257    }
2258
2259    #[test]
2260    fn test_derive_title_index_file_uses_parent_dir() {
2261        use std::path::PathBuf;
2262        let ctx = LintContext::new(
2263            "",
2264            crate::config::MarkdownFlavor::Standard,
2265            Some(PathBuf::from("docs/getting-started/index.md")),
2266        );
2267        let title = MD041FirstLineHeading::derive_title(&ctx);
2268        assert_eq!(title, Some("Getting Started".to_string()));
2269    }
2270
2271    #[test]
2272    fn test_derive_title_readme_file_uses_parent_dir() {
2273        use std::path::PathBuf;
2274        let ctx = LintContext::new(
2275            "",
2276            crate::config::MarkdownFlavor::Standard,
2277            Some(PathBuf::from("my-project/README.md")),
2278        );
2279        let title = MD041FirstLineHeading::derive_title(&ctx);
2280        assert_eq!(title, Some("My Project".to_string()));
2281    }
2282
2283    #[test]
2284    fn test_derive_title_index_without_parent_returns_none() {
2285        use std::path::PathBuf;
2286        // Root-level index.md has no meaningful parent β€” "Index" is not a useful title
2287        let ctx = LintContext::new(
2288            "",
2289            crate::config::MarkdownFlavor::Standard,
2290            Some(PathBuf::from("index.md")),
2291        );
2292        let title = MD041FirstLineHeading::derive_title(&ctx);
2293        assert_eq!(title, None);
2294    }
2295
2296    #[test]
2297    fn test_derive_title_readme_without_parent_returns_none() {
2298        use std::path::PathBuf;
2299        let ctx = LintContext::new(
2300            "",
2301            crate::config::MarkdownFlavor::Standard,
2302            Some(PathBuf::from("README.md")),
2303        );
2304        let title = MD041FirstLineHeading::derive_title(&ctx);
2305        assert_eq!(title, None);
2306    }
2307
2308    #[test]
2309    fn test_derive_title_readme_case_insensitive() {
2310        use std::path::PathBuf;
2311        // Lowercase readme.md should also use parent dir
2312        let ctx = LintContext::new(
2313            "",
2314            crate::config::MarkdownFlavor::Standard,
2315            Some(PathBuf::from("docs/api/readme.md")),
2316        );
2317        let title = MD041FirstLineHeading::derive_title(&ctx);
2318        assert_eq!(title, Some("Api".to_string()));
2319    }
2320
2321    #[test]
2322    fn test_is_title_candidate_basic() {
2323        assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2324        assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2325        assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2326    }
2327
2328    #[test]
2329    fn test_is_title_candidate_rejects_sentence_punctuation() {
2330        assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2331        assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2332        assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2333        assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2334        assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2335    }
2336
2337    #[test]
2338    fn test_is_title_candidate_rejects_when_no_blank_after() {
2339        assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2340    }
2341
2342    #[test]
2343    fn test_is_title_candidate_rejects_long_lines() {
2344        let long = "A".repeat(81);
2345        assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2346        // 80 chars is the boundary – exactly 80 is OK
2347        let ok = "A".repeat(80);
2348        assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2349    }
2350
2351    #[test]
2352    fn test_is_title_candidate_rejects_structural_markdown() {
2353        assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2354        assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2355        assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2356        assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2357    }
2358
2359    #[test]
2360    fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2361        // Verify that the fix replacement for plain-text-to-heading promotion is
2362        // non-empty, so applying the fix does not delete the line.
2363        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2364        // Title candidate: short text, no trailing punctuation, followed by blank line
2365        let content = "My Document Title\n\nMore content follows.";
2366        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2367        let warnings = rule.check(&ctx).unwrap();
2368        assert_eq!(warnings.len(), 1);
2369        let fix = warnings[0]
2370            .fix
2371            .as_ref()
2372            .expect("Fix should be present for promotable text");
2373        assert!(
2374            !fix.replacement.is_empty(),
2375            "Fix replacement must not be empty β€” applying it directly must produce valid output"
2376        );
2377        assert!(
2378            fix.replacement.starts_with("# "),
2379            "Fix replacement should be a level-1 heading, got: {:?}",
2380            fix.replacement
2381        );
2382        assert_eq!(fix.replacement, "# My Document Title");
2383    }
2384
2385    #[test]
2386    fn test_fix_replacement_not_empty_for_releveling() {
2387        // When the first line is a heading at the wrong level, the Fix should
2388        // contain the correctly-leveled heading, not an empty string.
2389        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2390        let content = "## Wrong Level\n\nContent.";
2391        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2392        let warnings = rule.check(&ctx).unwrap();
2393        assert_eq!(warnings.len(), 1);
2394        let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2395        assert!(
2396            !fix.replacement.is_empty(),
2397            "Fix replacement must not be empty for releveling"
2398        );
2399        assert_eq!(fix.replacement, "# Wrong Level");
2400    }
2401
2402    #[test]
2403    fn test_fix_replacement_applied_produces_valid_output() {
2404        // Verify that applying the Fix from check() produces the same result as fix()
2405        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2406        // Title candidate: short, no trailing punctuation, followed by blank line
2407        let content = "My Document\n\nMore content.";
2408        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2409
2410        let warnings = rule.check(&ctx).unwrap();
2411        assert_eq!(warnings.len(), 1);
2412        let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2413
2414        // Apply Fix directly (like LSP would)
2415        let mut patched = content.to_string();
2416        patched.replace_range(fix.range.clone(), &fix.replacement);
2417
2418        // Apply via fix() method
2419        let fixed = rule.fix(&ctx).unwrap();
2420
2421        assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2422    }
2423
2424    #[test]
2425    fn test_mdx_disable_on_line_1_no_heading() {
2426        // The exact user scenario from issue #538:
2427        // MDX disable comment on line 1, NO heading anywhere.
2428        // The disable is the ONLY reason MD041 should not fire.
2429        let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2431
2432        // check() should produce a warning on line 2 (<Note> is the first content line)
2433        let rule = MD041FirstLineHeading::default();
2434        let warnings = rule.check(&ctx).unwrap();
2435        // The rule itself produces a warning, but the engine filters it via inline config.
2436        // MD041's check() doesn't filter inline config itself β€” the engine does.
2437        // What matters is that the warning is on line 2 (not line 1), so the engine
2438        // can see the disable is active at line 2 and suppress it.
2439        if !warnings.is_empty() {
2440            assert_eq!(
2441                warnings[0].line, 2,
2442                "Warning must be on line 2 (first content line after MDX comment), not line 1"
2443            );
2444        }
2445    }
2446
2447    #[test]
2448    fn test_mdx_disable_fix_returns_unchanged() {
2449        // fix() should return content unchanged when MDX disable is active
2450        let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2452        let rule = MD041FirstLineHeading {
2453            fix_enabled: true,
2454            ..MD041FirstLineHeading::default()
2455        };
2456        let result = rule.fix(&ctx).unwrap();
2457        assert_eq!(
2458            result, content,
2459            "fix() should not modify content when MD041 is disabled via MDX comment"
2460        );
2461    }
2462
2463    #[test]
2464    fn test_mdx_comment_without_disable_heading_on_next_line() {
2465        let rule = MD041FirstLineHeading::default();
2466
2467        // MDX comment (not a disable directive) on line 1, heading on line 2
2468        let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2470        let result = rule.check(&ctx).unwrap();
2471        assert!(
2472            result.is_empty(),
2473            "MDX comment is preamble; heading on next line should satisfy MD041"
2474        );
2475    }
2476
2477    #[test]
2478    fn test_mdx_comment_without_heading_triggers_warning() {
2479        let rule = MD041FirstLineHeading::default();
2480
2481        // MDX comment on line 1, non-heading content on line 2
2482        let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2484        let result = rule.check(&ctx).unwrap();
2485        assert_eq!(
2486            result.len(),
2487            1,
2488            "MDX comment followed by non-heading should trigger MD041"
2489        );
2490        assert_eq!(
2491            result[0].line, 2,
2492            "Warning should be on line 2 (the first content line after MDX comment)"
2493        );
2494    }
2495
2496    #[test]
2497    fn test_multiline_mdx_comment_followed_by_heading() {
2498        let rule = MD041FirstLineHeading::default();
2499
2500        // Multi-line MDX comment followed by heading
2501        let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
2502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2503        let result = rule.check(&ctx).unwrap();
2504        assert!(
2505            result.is_empty(),
2506            "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
2507        );
2508    }
2509
2510    #[test]
2511    fn test_html_comment_still_works_as_preamble_regression() {
2512        let rule = MD041FirstLineHeading::default();
2513
2514        // Plain HTML comment on line 1, heading on line 2
2515        let content = "<!-- Some comment -->\n# My Document\n\nContent.";
2516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2517        let result = rule.check(&ctx).unwrap();
2518        assert!(
2519            result.is_empty(),
2520            "HTML comment should still be treated as preamble (regression test)"
2521        );
2522    }
2523}