Skip to main content

rumdl_lib/rules/md041_first_line_heading/
mod.rs

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