Skip to main content

rumdl_lib/rules/md041_first_line_heading/
mod.rs

1mod md041_config;
2
3pub 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)
636            .map(|i| i + 1) // 1-indexed
637            .unwrap_or(1);
638        if ctx.inline_config().is_rule_disabled(self.name(), first_content_line) {
639            return Ok(ctx.content.to_string());
640        }
641
642        let Some(plan) = self.analyze_for_fix(ctx) else {
643            return Ok(ctx.content.to_string());
644        };
645
646        let lines = ctx.raw_lines();
647
648        let mut result = String::new();
649        let preserve_trailing_newline = ctx.content.ends_with('\n');
650
651        match plan {
652            FixPlan::MoveOrRelevel {
653                front_matter_end_idx,
654                heading_idx,
655                is_setext,
656                current_level,
657                needs_level_fix,
658            } => {
659                let heading_line = ctx.lines[heading_idx].content(ctx.content);
660                let fixed_heading = if needs_level_fix || is_setext {
661                    self.fix_heading_level(heading_line, current_level, self.level)
662                } else {
663                    heading_line.to_string()
664                };
665
666                for line in lines.iter().take(front_matter_end_idx) {
667                    result.push_str(line);
668                    result.push('\n');
669                }
670                result.push_str(&fixed_heading);
671                result.push('\n');
672                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
673                    if idx == heading_idx {
674                        continue;
675                    }
676                    if is_setext && idx == heading_idx + 1 {
677                        continue;
678                    }
679                    result.push_str(line);
680                    result.push('\n');
681                }
682            }
683
684            FixPlan::PromotePlainText {
685                front_matter_end_idx,
686                title_line_idx,
687                title_text,
688            } => {
689                let hashes = "#".repeat(self.level);
690                let new_heading = format!("{hashes} {title_text}");
691
692                for line in lines.iter().take(front_matter_end_idx) {
693                    result.push_str(line);
694                    result.push('\n');
695                }
696                result.push_str(&new_heading);
697                result.push('\n');
698                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
699                    if idx == title_line_idx {
700                        continue;
701                    }
702                    result.push_str(line);
703                    result.push('\n');
704                }
705            }
706
707            FixPlan::InsertDerived {
708                front_matter_end_idx,
709                derived_title,
710            } => {
711                let hashes = "#".repeat(self.level);
712                let new_heading = format!("{hashes} {derived_title}");
713
714                for line in lines.iter().take(front_matter_end_idx) {
715                    result.push_str(line);
716                    result.push('\n');
717                }
718                result.push_str(&new_heading);
719                result.push('\n');
720                result.push('\n');
721                for line in lines.iter().skip(front_matter_end_idx) {
722                    result.push_str(line);
723                    result.push('\n');
724                }
725            }
726        }
727
728        if !preserve_trailing_newline && result.ends_with('\n') {
729            result.pop();
730        }
731
732        Ok(result)
733    }
734
735    /// Check if this rule should be skipped
736    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
737        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
738        // These files are composition/routing metadata, not standalone content.
739        // Example: A file containing only "{{#include ../../README.md}}" is a
740        // pointer to content, not content itself, and shouldn't need a heading.
741        let only_directives = !ctx.content.is_empty()
742            && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
743                let t = l.trim();
744                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
745                (t.starts_with("{{#") && t.ends_with("}}"))
746                        // HTML comments often accompany directives
747                        || (t.starts_with("<!--") && t.ends_with("-->"))
748            });
749
750        ctx.content.is_empty()
751            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
752            || only_directives
753    }
754
755    fn as_any(&self) -> &dyn std::any::Any {
756        self
757    }
758
759    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
760    where
761        Self: Sized,
762    {
763        // Load config using serde with kebab-case support
764        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
765
766        let use_front_matter = !md041_config.front_matter_title.is_empty();
767
768        Box::new(MD041FirstLineHeading::with_pattern(
769            md041_config.level.as_usize(),
770            use_front_matter,
771            md041_config.front_matter_title_pattern,
772            md041_config.fix,
773        ))
774    }
775
776    fn default_config_section(&self) -> Option<(String, toml::Value)> {
777        Some((
778            "MD041".to_string(),
779            toml::toml! {
780                level = 1
781                front-matter-title = "title"
782                front-matter-title-pattern = ""
783                fix = false
784            }
785            .into(),
786        ))
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use crate::lint_context::LintContext;
794
795    #[test]
796    fn test_first_line_is_heading_correct_level() {
797        let rule = MD041FirstLineHeading::default();
798
799        // First line is a level 1 heading (should pass)
800        let content = "# My Document\n\nSome content here.";
801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802        let result = rule.check(&ctx).unwrap();
803        assert!(
804            result.is_empty(),
805            "Expected no warnings when first line is a level 1 heading"
806        );
807    }
808
809    #[test]
810    fn test_first_line_is_heading_wrong_level() {
811        let rule = MD041FirstLineHeading::default();
812
813        // First line is a level 2 heading (should fail with level 1 requirement)
814        let content = "## My Document\n\nSome content here.";
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816        let result = rule.check(&ctx).unwrap();
817        assert_eq!(result.len(), 1);
818        assert_eq!(result[0].line, 1);
819        assert!(result[0].message.contains("level 1 heading"));
820    }
821
822    #[test]
823    fn test_first_line_not_heading() {
824        let rule = MD041FirstLineHeading::default();
825
826        // First line is plain text (should fail)
827        let content = "This is not a heading\n\n# This is a heading";
828        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829        let result = rule.check(&ctx).unwrap();
830        assert_eq!(result.len(), 1);
831        assert_eq!(result[0].line, 1);
832        assert!(result[0].message.contains("level 1 heading"));
833    }
834
835    #[test]
836    fn test_empty_lines_before_heading() {
837        let rule = MD041FirstLineHeading::default();
838
839        // Empty lines before first heading (should pass - rule skips empty lines)
840        let content = "\n\n# My Document\n\nSome content.";
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842        let result = rule.check(&ctx).unwrap();
843        assert!(
844            result.is_empty(),
845            "Expected no warnings when empty lines precede a valid heading"
846        );
847
848        // Empty lines before non-heading content (should fail)
849        let content = "\n\nNot a heading\n\nSome content.";
850        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851        let result = rule.check(&ctx).unwrap();
852        assert_eq!(result.len(), 1);
853        assert_eq!(result[0].line, 3); // First non-empty line
854        assert!(result[0].message.contains("level 1 heading"));
855    }
856
857    #[test]
858    fn test_front_matter_with_title() {
859        let rule = MD041FirstLineHeading::new(1, true);
860
861        // Front matter with title field (should pass)
862        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865        assert!(
866            result.is_empty(),
867            "Expected no warnings when front matter has title field"
868        );
869    }
870
871    #[test]
872    fn test_front_matter_without_title() {
873        let rule = MD041FirstLineHeading::new(1, true);
874
875        // Front matter without title field (should fail)
876        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
877        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
878        let result = rule.check(&ctx).unwrap();
879        assert_eq!(result.len(), 1);
880        assert_eq!(result[0].line, 6); // First content line after front matter
881    }
882
883    #[test]
884    fn test_front_matter_disabled() {
885        let rule = MD041FirstLineHeading::new(1, false);
886
887        // Front matter with title field but front_matter_title is false (should fail)
888        let content = "---\ntitle: My Document\n---\n\nSome content here.";
889        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890        let result = rule.check(&ctx).unwrap();
891        assert_eq!(result.len(), 1);
892        assert_eq!(result[0].line, 5); // First content line after front matter
893    }
894
895    #[test]
896    fn test_html_comments_before_heading() {
897        let rule = MD041FirstLineHeading::default();
898
899        // HTML comment before heading (should pass - comments are skipped, issue #155)
900        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
901        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902        let result = rule.check(&ctx).unwrap();
903        assert!(
904            result.is_empty(),
905            "HTML comments should be skipped when checking for first heading"
906        );
907    }
908
909    #[test]
910    fn test_multiline_html_comment_before_heading() {
911        let rule = MD041FirstLineHeading::default();
912
913        // Multi-line HTML comment before heading (should pass - issue #155)
914        let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let result = rule.check(&ctx).unwrap();
917        assert!(
918            result.is_empty(),
919            "Multi-line HTML comments should be skipped when checking for first heading"
920        );
921    }
922
923    #[test]
924    fn test_html_comment_with_blank_lines_before_heading() {
925        let rule = MD041FirstLineHeading::default();
926
927        // HTML comment with blank lines before heading (should pass - issue #155)
928        let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931        assert!(
932            result.is_empty(),
933            "HTML comments with blank lines should be skipped when checking for first heading"
934        );
935    }
936
937    #[test]
938    fn test_html_comment_before_html_heading() {
939        let rule = MD041FirstLineHeading::default();
940
941        // HTML comment before HTML heading (should pass - issue #155)
942        let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
943        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944        let result = rule.check(&ctx).unwrap();
945        assert!(
946            result.is_empty(),
947            "HTML comments should be skipped before HTML headings"
948        );
949    }
950
951    #[test]
952    fn test_document_with_only_html_comments() {
953        let rule = MD041FirstLineHeading::default();
954
955        // Document with only HTML comments (should pass - no warnings for comment-only files)
956        let content = "<!-- This is a comment -->\n<!-- Another comment -->";
957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958        let result = rule.check(&ctx).unwrap();
959        assert!(
960            result.is_empty(),
961            "Documents with only HTML comments should not trigger MD041"
962        );
963    }
964
965    #[test]
966    fn test_html_comment_followed_by_non_heading() {
967        let rule = MD041FirstLineHeading::default();
968
969        // HTML comment followed by non-heading content (should still fail - issue #155)
970        let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972        let result = rule.check(&ctx).unwrap();
973        assert_eq!(
974            result.len(),
975            1,
976            "HTML comment followed by non-heading should still trigger MD041"
977        );
978        assert_eq!(
979            result[0].line, 2,
980            "Warning should be on the first non-comment, non-heading line"
981        );
982    }
983
984    #[test]
985    fn test_multiple_html_comments_before_heading() {
986        let rule = MD041FirstLineHeading::default();
987
988        // Multiple HTML comments before heading (should pass - issue #155)
989        let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
990        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991        let result = rule.check(&ctx).unwrap();
992        assert!(
993            result.is_empty(),
994            "Multiple HTML comments should all be skipped before heading"
995        );
996    }
997
998    #[test]
999    fn test_html_comment_with_wrong_level_heading() {
1000        let rule = MD041FirstLineHeading::default();
1001
1002        // HTML comment followed by wrong-level heading (should fail - issue #155)
1003        let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1004        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005        let result = rule.check(&ctx).unwrap();
1006        assert_eq!(
1007            result.len(),
1008            1,
1009            "HTML comment followed by wrong-level heading should still trigger MD041"
1010        );
1011        assert!(
1012            result[0].message.contains("level 1 heading"),
1013            "Should require level 1 heading"
1014        );
1015    }
1016
1017    #[test]
1018    fn test_html_comment_mixed_with_reference_definitions() {
1019        let rule = MD041FirstLineHeading::default();
1020
1021        // HTML comment mixed with reference definitions before heading (should pass - issue #155)
1022        let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1023        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024        let result = rule.check(&ctx).unwrap();
1025        assert!(
1026            result.is_empty(),
1027            "HTML comments and reference definitions should both be skipped before heading"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_html_comment_after_front_matter() {
1033        let rule = MD041FirstLineHeading::default();
1034
1035        // HTML comment after front matter, before heading (should pass - issue #155)
1036        let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1037        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038        let result = rule.check(&ctx).unwrap();
1039        assert!(
1040            result.is_empty(),
1041            "HTML comments after front matter should be skipped before heading"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_html_comment_not_at_start_should_not_affect_rule() {
1047        let rule = MD041FirstLineHeading::default();
1048
1049        // HTML comment in middle of document should not affect MD041 check
1050        let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1051        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052        let result = rule.check(&ctx).unwrap();
1053        assert!(
1054            result.is_empty(),
1055            "HTML comments in middle of document should not affect MD041 (only first content matters)"
1056        );
1057    }
1058
1059    #[test]
1060    fn test_multiline_html_comment_followed_by_non_heading() {
1061        let rule = MD041FirstLineHeading::default();
1062
1063        // Multi-line HTML comment followed by non-heading (should still fail - issue #155)
1064        let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1065        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066        let result = rule.check(&ctx).unwrap();
1067        assert_eq!(
1068            result.len(),
1069            1,
1070            "Multi-line HTML comment followed by non-heading should still trigger MD041"
1071        );
1072        assert_eq!(
1073            result[0].line, 5,
1074            "Warning should be on the first non-comment, non-heading line"
1075        );
1076    }
1077
1078    #[test]
1079    fn test_different_heading_levels() {
1080        // Test with level 2 requirement
1081        let rule = MD041FirstLineHeading::new(2, false);
1082
1083        let content = "## Second Level Heading\n\nContent.";
1084        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085        let result = rule.check(&ctx).unwrap();
1086        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1087
1088        // Wrong level
1089        let content = "# First Level Heading\n\nContent.";
1090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091        let result = rule.check(&ctx).unwrap();
1092        assert_eq!(result.len(), 1);
1093        assert!(result[0].message.contains("level 2 heading"));
1094    }
1095
1096    #[test]
1097    fn test_setext_headings() {
1098        let rule = MD041FirstLineHeading::default();
1099
1100        // Setext style level 1 heading (should pass)
1101        let content = "My Document\n===========\n\nContent.";
1102        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103        let result = rule.check(&ctx).unwrap();
1104        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1105
1106        // Setext style level 2 heading (should fail with level 1 requirement)
1107        let content = "My Document\n-----------\n\nContent.";
1108        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109        let result = rule.check(&ctx).unwrap();
1110        assert_eq!(result.len(), 1);
1111        assert!(result[0].message.contains("level 1 heading"));
1112    }
1113
1114    #[test]
1115    fn test_empty_document() {
1116        let rule = MD041FirstLineHeading::default();
1117
1118        // Empty document (should pass - no warnings)
1119        let content = "";
1120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121        let result = rule.check(&ctx).unwrap();
1122        assert!(result.is_empty(), "Expected no warnings for empty document");
1123    }
1124
1125    #[test]
1126    fn test_whitespace_only_document() {
1127        let rule = MD041FirstLineHeading::default();
1128
1129        // Document with only whitespace (should pass - no warnings)
1130        let content = "   \n\n   \t\n";
1131        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132        let result = rule.check(&ctx).unwrap();
1133        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1134    }
1135
1136    #[test]
1137    fn test_front_matter_then_whitespace() {
1138        let rule = MD041FirstLineHeading::default();
1139
1140        // Front matter followed by only whitespace (should pass - no warnings)
1141        let content = "---\ntitle: Test\n---\n\n   \n\n";
1142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143        let result = rule.check(&ctx).unwrap();
1144        assert!(
1145            result.is_empty(),
1146            "Expected no warnings when no content after front matter"
1147        );
1148    }
1149
1150    #[test]
1151    fn test_multiple_front_matter_types() {
1152        let rule = MD041FirstLineHeading::new(1, true);
1153
1154        // TOML front matter with title (should pass - title satisfies heading requirement)
1155        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1156        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157        let result = rule.check(&ctx).unwrap();
1158        assert!(
1159            result.is_empty(),
1160            "Expected no warnings for TOML front matter with title"
1161        );
1162
1163        // JSON front matter with title (should pass)
1164        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1165        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166        let result = rule.check(&ctx).unwrap();
1167        assert!(
1168            result.is_empty(),
1169            "Expected no warnings for JSON front matter with title"
1170        );
1171
1172        // YAML front matter with title field (standard case)
1173        let content = "---\ntitle: My Document\n---\n\nContent.";
1174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175        let result = rule.check(&ctx).unwrap();
1176        assert!(
1177            result.is_empty(),
1178            "Expected no warnings for YAML front matter with title"
1179        );
1180    }
1181
1182    #[test]
1183    fn test_toml_front_matter_with_heading() {
1184        let rule = MD041FirstLineHeading::default();
1185
1186        // TOML front matter followed by correct heading (should pass)
1187        let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1188        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189        let result = rule.check(&ctx).unwrap();
1190        assert!(
1191            result.is_empty(),
1192            "Expected no warnings when heading follows TOML front matter"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_toml_front_matter_without_title_no_heading() {
1198        let rule = MD041FirstLineHeading::new(1, true);
1199
1200        // TOML front matter without title, no heading (should warn)
1201        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203        let result = rule.check(&ctx).unwrap();
1204        assert_eq!(result.len(), 1);
1205        assert_eq!(result[0].line, 6);
1206    }
1207
1208    #[test]
1209    fn test_toml_front_matter_level_2_heading() {
1210        // Reproduces the exact scenario from issue #427
1211        let rule = MD041FirstLineHeading::new(2, true);
1212
1213        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1214        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215        let result = rule.check(&ctx).unwrap();
1216        assert!(
1217            result.is_empty(),
1218            "Issue #427: TOML front matter with title and correct heading level should not warn"
1219        );
1220    }
1221
1222    #[test]
1223    fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1224        // Reproduces the exact config shape from issue #427
1225        let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1226
1227        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1228        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229        let result = rule.check(&ctx).unwrap();
1230        assert!(
1231            result.is_empty(),
1232            "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1233        );
1234    }
1235
1236    #[test]
1237    fn test_json_front_matter_with_heading() {
1238        let rule = MD041FirstLineHeading::default();
1239
1240        // JSON front matter followed by correct heading
1241        let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243        let result = rule.check(&ctx).unwrap();
1244        assert!(
1245            result.is_empty(),
1246            "Expected no warnings when heading follows JSON front matter"
1247        );
1248    }
1249
1250    #[test]
1251    fn test_malformed_front_matter() {
1252        let rule = MD041FirstLineHeading::new(1, true);
1253
1254        // Malformed front matter with title
1255        let content = "- --\ntitle: My Document\n- --\n\nContent.";
1256        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257        let result = rule.check(&ctx).unwrap();
1258        assert!(
1259            result.is_empty(),
1260            "Expected no warnings for malformed front matter with title"
1261        );
1262    }
1263
1264    #[test]
1265    fn test_front_matter_with_heading() {
1266        let rule = MD041FirstLineHeading::default();
1267
1268        // Front matter without title field followed by correct heading
1269        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1270        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271        let result = rule.check(&ctx).unwrap();
1272        assert!(
1273            result.is_empty(),
1274            "Expected no warnings when first line after front matter is correct heading"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_no_fix_suggestion() {
1280        let rule = MD041FirstLineHeading::default();
1281
1282        // Check that NO fix suggestion is provided (MD041 is now detection-only)
1283        let content = "Not a heading\n\nContent.";
1284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285        let result = rule.check(&ctx).unwrap();
1286        assert_eq!(result.len(), 1);
1287        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1288    }
1289
1290    #[test]
1291    fn test_complex_document_structure() {
1292        let rule = MD041FirstLineHeading::default();
1293
1294        // Complex document with various elements - HTML comment should be skipped (issue #155)
1295        let content =
1296            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        let result = rule.check(&ctx).unwrap();
1299        assert!(
1300            result.is_empty(),
1301            "HTML comments should be skipped, so first heading after comment should be valid"
1302        );
1303    }
1304
1305    #[test]
1306    fn test_heading_with_special_characters() {
1307        let rule = MD041FirstLineHeading::default();
1308
1309        // Heading with special characters and formatting
1310        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1311        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312        let result = rule.check(&ctx).unwrap();
1313        assert!(
1314            result.is_empty(),
1315            "Expected no warnings for heading with inline formatting"
1316        );
1317    }
1318
1319    #[test]
1320    fn test_level_configuration() {
1321        // Test various level configurations
1322        for level in 1..=6 {
1323            let rule = MD041FirstLineHeading::new(level, false);
1324
1325            // Correct level
1326            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1327            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1328            let result = rule.check(&ctx).unwrap();
1329            assert!(
1330                result.is_empty(),
1331                "Expected no warnings for correct level {level} heading"
1332            );
1333
1334            // Wrong level
1335            let wrong_level = if level == 1 { 2 } else { 1 };
1336            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1337            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1338            let result = rule.check(&ctx).unwrap();
1339            assert_eq!(result.len(), 1);
1340            assert!(result[0].message.contains(&format!("level {level} heading")));
1341        }
1342    }
1343
1344    #[test]
1345    fn test_issue_152_multiline_html_heading() {
1346        let rule = MD041FirstLineHeading::default();
1347
1348        // Multi-line HTML h1 heading (should pass - issue #152)
1349        let content = "<h1>\nSome text\n</h1>";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351        let result = rule.check(&ctx).unwrap();
1352        assert!(
1353            result.is_empty(),
1354            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_multiline_html_heading_with_attributes() {
1360        let rule = MD041FirstLineHeading::default();
1361
1362        // Multi-line HTML heading with attributes
1363        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1364        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1365        let result = rule.check(&ctx).unwrap();
1366        assert!(
1367            result.is_empty(),
1368            "Multi-line HTML heading with attributes should be recognized"
1369        );
1370    }
1371
1372    #[test]
1373    fn test_multiline_html_heading_wrong_level() {
1374        let rule = MD041FirstLineHeading::default();
1375
1376        // Multi-line HTML h2 heading (should fail with level 1 requirement)
1377        let content = "<h2>\nSome text\n</h2>";
1378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379        let result = rule.check(&ctx).unwrap();
1380        assert_eq!(result.len(), 1);
1381        assert!(result[0].message.contains("level 1 heading"));
1382    }
1383
1384    #[test]
1385    fn test_multiline_html_heading_with_content_after() {
1386        let rule = MD041FirstLineHeading::default();
1387
1388        // Multi-line HTML heading followed by content
1389        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391        let result = rule.check(&ctx).unwrap();
1392        assert!(
1393            result.is_empty(),
1394            "Multi-line HTML heading followed by content should be valid"
1395        );
1396    }
1397
1398    #[test]
1399    fn test_multiline_html_heading_incomplete() {
1400        let rule = MD041FirstLineHeading::default();
1401
1402        // Incomplete multi-line HTML heading (missing closing tag)
1403        let content = "<h1>\nSome text\n\nMore content without closing tag";
1404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405        let result = rule.check(&ctx).unwrap();
1406        assert_eq!(result.len(), 1);
1407        assert!(result[0].message.contains("level 1 heading"));
1408    }
1409
1410    #[test]
1411    fn test_singleline_html_heading_still_works() {
1412        let rule = MD041FirstLineHeading::default();
1413
1414        // Single-line HTML heading should still work
1415        let content = "<h1>My Document</h1>\n\nContent.";
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417        let result = rule.check(&ctx).unwrap();
1418        assert!(
1419            result.is_empty(),
1420            "Single-line HTML headings should still be recognized"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_multiline_html_heading_with_nested_tags() {
1426        let rule = MD041FirstLineHeading::default();
1427
1428        // Multi-line HTML heading with nested tags
1429        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431        let result = rule.check(&ctx).unwrap();
1432        assert!(
1433            result.is_empty(),
1434            "Multi-line HTML heading with nested tags should be recognized"
1435        );
1436    }
1437
1438    #[test]
1439    fn test_multiline_html_heading_various_levels() {
1440        // Test multi-line headings at different levels
1441        for level in 1..=6 {
1442            let rule = MD041FirstLineHeading::new(level, false);
1443
1444            // Correct level multi-line
1445            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1446            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1447            let result = rule.check(&ctx).unwrap();
1448            assert!(
1449                result.is_empty(),
1450                "Multi-line HTML heading at level {level} should be recognized"
1451            );
1452
1453            // Wrong level multi-line
1454            let wrong_level = if level == 1 { 2 } else { 1 };
1455            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1456            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1457            let result = rule.check(&ctx).unwrap();
1458            assert_eq!(result.len(), 1);
1459            assert!(result[0].message.contains(&format!("level {level} heading")));
1460        }
1461    }
1462
1463    #[test]
1464    fn test_issue_152_nested_heading_spans_many_lines() {
1465        let rule = MD041FirstLineHeading::default();
1466
1467        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>";
1468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469        let result = rule.check(&ctx).unwrap();
1470        assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1471    }
1472
1473    #[test]
1474    fn test_issue_152_picture_tag_heading() {
1475        let rule = MD041FirstLineHeading::default();
1476
1477        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>";
1478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479        let result = rule.check(&ctx).unwrap();
1480        assert!(
1481            result.is_empty(),
1482            "Picture tag inside multi-line HTML heading should be recognized"
1483        );
1484    }
1485
1486    #[test]
1487    fn test_badge_images_before_heading() {
1488        let rule = MD041FirstLineHeading::default();
1489
1490        // Single badge before heading
1491        let content = "![badge](https://img.shields.io/badge/test-passing-green)\n\n# My Project";
1492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1493        let result = rule.check(&ctx).unwrap();
1494        assert!(result.is_empty(), "Badge image should be skipped");
1495
1496        // Multiple badges on one line
1497        let content = "![badge1](url1) ![badge2](url2)\n\n# My Project";
1498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1499        let result = rule.check(&ctx).unwrap();
1500        assert!(result.is_empty(), "Multiple badges should be skipped");
1501
1502        // Linked badge (clickable)
1503        let content = "[![badge](https://img.shields.io/badge/test-pass-green)](https://example.com)\n\n# My Project";
1504        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505        let result = rule.check(&ctx).unwrap();
1506        assert!(result.is_empty(), "Linked badge should be skipped");
1507    }
1508
1509    #[test]
1510    fn test_multiple_badge_lines_before_heading() {
1511        let rule = MD041FirstLineHeading::default();
1512
1513        // Multiple lines of badges
1514        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";
1515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516        let result = rule.check(&ctx).unwrap();
1517        assert!(result.is_empty(), "Multiple badge lines should be skipped");
1518    }
1519
1520    #[test]
1521    fn test_badges_without_heading_still_warns() {
1522        let rule = MD041FirstLineHeading::default();
1523
1524        // Badges followed by paragraph (not heading)
1525        let content = "![badge](url)\n\nThis is not a heading.";
1526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527        let result = rule.check(&ctx).unwrap();
1528        assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1529    }
1530
1531    #[test]
1532    fn test_mixed_content_not_badge_line() {
1533        let rule = MD041FirstLineHeading::default();
1534
1535        // Image with text is not a badge line
1536        let content = "![badge](url) Some text here\n\n# Heading";
1537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538        let result = rule.check(&ctx).unwrap();
1539        assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1540    }
1541
1542    #[test]
1543    fn test_is_badge_image_line_unit() {
1544        // Unit tests for is_badge_image_line
1545        assert!(MD041FirstLineHeading::is_badge_image_line("![badge](url)"));
1546        assert!(MD041FirstLineHeading::is_badge_image_line("[![badge](img)](link)"));
1547        assert!(MD041FirstLineHeading::is_badge_image_line("![a](b) ![c](d)"));
1548        assert!(MD041FirstLineHeading::is_badge_image_line("[![a](b)](c) [![d](e)](f)"));
1549
1550        // Not badge lines
1551        assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1552        assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1553        assert!(!MD041FirstLineHeading::is_badge_image_line("![badge](url) text"));
1554        assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1555    }
1556
1557    // Integration tests for MkDocs anchor line detection (issue #365)
1558    // Unit tests for is_mkdocs_anchor_line are in utils/mkdocs_attr_list.rs
1559
1560    #[test]
1561    fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1562        let rule = MD041FirstLineHeading::default();
1563
1564        // MkDocs anchor line before heading in MkDocs flavor (should pass)
1565        let content = "[](){ #example }\n# Title";
1566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1567        let result = rule.check(&ctx).unwrap();
1568        assert!(
1569            result.is_empty(),
1570            "MkDocs anchor line should be skipped in MkDocs flavor"
1571        );
1572    }
1573
1574    #[test]
1575    fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1576        let rule = MD041FirstLineHeading::default();
1577
1578        // MkDocs anchor line before heading in Standard flavor (should fail)
1579        let content = "[](){ #example }\n# Title";
1580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581        let result = rule.check(&ctx).unwrap();
1582        assert_eq!(
1583            result.len(),
1584            1,
1585            "MkDocs anchor line should NOT be skipped in Standard flavor"
1586        );
1587    }
1588
1589    #[test]
1590    fn test_multiple_mkdocs_anchors_before_heading() {
1591        let rule = MD041FirstLineHeading::default();
1592
1593        // Multiple MkDocs anchor lines before heading in MkDocs flavor
1594        let content = "[](){ #first }\n[](){ #second }\n# Title";
1595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1596        let result = rule.check(&ctx).unwrap();
1597        assert!(
1598            result.is_empty(),
1599            "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1600        );
1601    }
1602
1603    #[test]
1604    fn test_mkdocs_anchor_with_front_matter() {
1605        let rule = MD041FirstLineHeading::default();
1606
1607        // MkDocs anchor after front matter
1608        let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1609        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1610        let result = rule.check(&ctx).unwrap();
1611        assert!(
1612            result.is_empty(),
1613            "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1614        );
1615    }
1616
1617    #[test]
1618    fn test_mkdocs_anchor_kramdown_style() {
1619        let rule = MD041FirstLineHeading::default();
1620
1621        // Kramdown-style with colon
1622        let content = "[](){: #anchor }\n# Title";
1623        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1624        let result = rule.check(&ctx).unwrap();
1625        assert!(
1626            result.is_empty(),
1627            "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1628        );
1629    }
1630
1631    #[test]
1632    fn test_mkdocs_anchor_without_heading_still_warns() {
1633        let rule = MD041FirstLineHeading::default();
1634
1635        // MkDocs anchor followed by non-heading content
1636        let content = "[](){ #anchor }\nThis is not a heading.";
1637        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1638        let result = rule.check(&ctx).unwrap();
1639        assert_eq!(
1640            result.len(),
1641            1,
1642            "MkDocs anchor followed by non-heading should still trigger MD041"
1643        );
1644    }
1645
1646    #[test]
1647    fn test_mkdocs_anchor_with_html_comment() {
1648        let rule = MD041FirstLineHeading::default();
1649
1650        // MkDocs anchor combined with HTML comment before heading
1651        let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1653        let result = rule.check(&ctx).unwrap();
1654        assert!(
1655            result.is_empty(),
1656            "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1657        );
1658    }
1659
1660    // Tests for auto-fix functionality (issue #359)
1661
1662    #[test]
1663    fn test_fix_disabled_by_default() {
1664        use crate::rule::Rule;
1665        let rule = MD041FirstLineHeading::default();
1666
1667        // Fix should not change content when disabled
1668        let content = "## Wrong Level\n\nContent.";
1669        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670        let fixed = rule.fix(&ctx).unwrap();
1671        assert_eq!(fixed, content, "Fix should not change content when disabled");
1672    }
1673
1674    #[test]
1675    fn test_fix_wrong_heading_level() {
1676        use crate::rule::Rule;
1677        let rule = MD041FirstLineHeading {
1678            level: 1,
1679            front_matter_title: false,
1680            front_matter_title_pattern: None,
1681            fix_enabled: true,
1682        };
1683
1684        // ## should become #
1685        let content = "## Wrong Level\n\nContent.\n";
1686        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687        let fixed = rule.fix(&ctx).unwrap();
1688        assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1689    }
1690
1691    #[test]
1692    fn test_fix_heading_after_preamble() {
1693        use crate::rule::Rule;
1694        let rule = MD041FirstLineHeading {
1695            level: 1,
1696            front_matter_title: false,
1697            front_matter_title_pattern: None,
1698            fix_enabled: true,
1699        };
1700
1701        // Heading after blank lines should be moved up
1702        let content = "\n\n# Title\n\nContent.\n";
1703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1704        let fixed = rule.fix(&ctx).unwrap();
1705        assert!(
1706            fixed.starts_with("# Title\n"),
1707            "Heading should be moved to first line, got: {fixed}"
1708        );
1709    }
1710
1711    #[test]
1712    fn test_fix_heading_after_html_comment() {
1713        use crate::rule::Rule;
1714        let rule = MD041FirstLineHeading {
1715            level: 1,
1716            front_matter_title: false,
1717            front_matter_title_pattern: None,
1718            fix_enabled: true,
1719        };
1720
1721        // Heading after HTML comment should be moved up
1722        let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724        let fixed = rule.fix(&ctx).unwrap();
1725        assert!(
1726            fixed.starts_with("# Title\n"),
1727            "Heading should be moved above comment, got: {fixed}"
1728        );
1729    }
1730
1731    #[test]
1732    fn test_fix_heading_level_and_move() {
1733        use crate::rule::Rule;
1734        let rule = MD041FirstLineHeading {
1735            level: 1,
1736            front_matter_title: false,
1737            front_matter_title_pattern: None,
1738            fix_enabled: true,
1739        };
1740
1741        // Heading with wrong level after preamble should be fixed and moved
1742        let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1744        let fixed = rule.fix(&ctx).unwrap();
1745        assert!(
1746            fixed.starts_with("# Wrong Level\n"),
1747            "Heading should be fixed and moved, got: {fixed}"
1748        );
1749    }
1750
1751    #[test]
1752    fn test_fix_with_front_matter() {
1753        use crate::rule::Rule;
1754        let rule = MD041FirstLineHeading {
1755            level: 1,
1756            front_matter_title: false,
1757            front_matter_title_pattern: None,
1758            fix_enabled: true,
1759        };
1760
1761        // Heading after front matter and preamble
1762        let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764        let fixed = rule.fix(&ctx).unwrap();
1765        assert!(
1766            fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1767            "Heading should be right after front matter, got: {fixed}"
1768        );
1769    }
1770
1771    #[test]
1772    fn test_fix_with_toml_front_matter() {
1773        use crate::rule::Rule;
1774        let rule = MD041FirstLineHeading {
1775            level: 1,
1776            front_matter_title: false,
1777            front_matter_title_pattern: None,
1778            fix_enabled: true,
1779        };
1780
1781        // Heading after TOML front matter and preamble
1782        let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1783        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784        let fixed = rule.fix(&ctx).unwrap();
1785        assert!(
1786            fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1787            "Heading should be right after TOML front matter, got: {fixed}"
1788        );
1789    }
1790
1791    #[test]
1792    fn test_fix_cannot_fix_no_heading() {
1793        use crate::rule::Rule;
1794        let rule = MD041FirstLineHeading {
1795            level: 1,
1796            front_matter_title: false,
1797            front_matter_title_pattern: None,
1798            fix_enabled: true,
1799        };
1800
1801        // No heading in document - cannot fix
1802        let content = "Just some text.\n\nMore text.\n";
1803        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1804        let fixed = rule.fix(&ctx).unwrap();
1805        assert_eq!(fixed, content, "Should not change content when no heading exists");
1806    }
1807
1808    #[test]
1809    fn test_fix_cannot_fix_content_before_heading() {
1810        use crate::rule::Rule;
1811        let rule = MD041FirstLineHeading {
1812            level: 1,
1813            front_matter_title: false,
1814            front_matter_title_pattern: None,
1815            fix_enabled: true,
1816        };
1817
1818        // Real content before heading - cannot safely fix
1819        let content = "Some intro text.\n\n# Title\n\nContent.\n";
1820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821        let fixed = rule.fix(&ctx).unwrap();
1822        assert_eq!(
1823            fixed, content,
1824            "Should not change content when real content exists before heading"
1825        );
1826    }
1827
1828    #[test]
1829    fn test_fix_already_correct() {
1830        use crate::rule::Rule;
1831        let rule = MD041FirstLineHeading {
1832            level: 1,
1833            front_matter_title: false,
1834            front_matter_title_pattern: None,
1835            fix_enabled: true,
1836        };
1837
1838        // Already correct - no changes needed
1839        let content = "# Title\n\nContent.\n";
1840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1841        let fixed = rule.fix(&ctx).unwrap();
1842        assert_eq!(fixed, content, "Should not change already correct content");
1843    }
1844
1845    #[test]
1846    fn test_fix_setext_heading_removes_underline() {
1847        use crate::rule::Rule;
1848        let rule = MD041FirstLineHeading {
1849            level: 1,
1850            front_matter_title: false,
1851            front_matter_title_pattern: None,
1852            fix_enabled: true,
1853        };
1854
1855        // Setext heading (level 2 with --- underline)
1856        let content = "Wrong Level\n-----------\n\nContent.\n";
1857        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1858        let fixed = rule.fix(&ctx).unwrap();
1859        assert_eq!(
1860            fixed, "# Wrong Level\n\nContent.\n",
1861            "Setext heading should be converted to ATX and underline removed"
1862        );
1863    }
1864
1865    #[test]
1866    fn test_fix_setext_h1_heading() {
1867        use crate::rule::Rule;
1868        let rule = MD041FirstLineHeading {
1869            level: 1,
1870            front_matter_title: false,
1871            front_matter_title_pattern: None,
1872            fix_enabled: true,
1873        };
1874
1875        // Setext h1 heading (=== underline) after preamble - needs move but not level fix
1876        let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1877        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1878        let fixed = rule.fix(&ctx).unwrap();
1879        assert_eq!(
1880            fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1881            "Setext h1 should be moved and converted to ATX"
1882        );
1883    }
1884
1885    #[test]
1886    fn test_html_heading_not_claimed_fixable() {
1887        use crate::rule::Rule;
1888        let rule = MD041FirstLineHeading {
1889            level: 1,
1890            front_matter_title: false,
1891            front_matter_title_pattern: None,
1892            fix_enabled: true,
1893        };
1894
1895        // HTML heading - should NOT be claimed as fixable (we can't convert HTML to ATX)
1896        let content = "<h2>Title</h2>\n\nContent.\n";
1897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1898        let warnings = rule.check(&ctx).unwrap();
1899        assert_eq!(warnings.len(), 1);
1900        assert!(
1901            warnings[0].fix.is_none(),
1902            "HTML heading should not be claimed as fixable"
1903        );
1904    }
1905
1906    #[test]
1907    fn test_no_heading_not_claimed_fixable() {
1908        use crate::rule::Rule;
1909        let rule = MD041FirstLineHeading {
1910            level: 1,
1911            front_matter_title: false,
1912            front_matter_title_pattern: None,
1913            fix_enabled: true,
1914        };
1915
1916        // No heading in document - should NOT be claimed as fixable
1917        let content = "Just some text.\n\nMore text.\n";
1918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919        let warnings = rule.check(&ctx).unwrap();
1920        assert_eq!(warnings.len(), 1);
1921        assert!(
1922            warnings[0].fix.is_none(),
1923            "Document without heading should not be claimed as fixable"
1924        );
1925    }
1926
1927    #[test]
1928    fn test_content_before_heading_not_claimed_fixable() {
1929        use crate::rule::Rule;
1930        let rule = MD041FirstLineHeading {
1931            level: 1,
1932            front_matter_title: false,
1933            front_matter_title_pattern: None,
1934            fix_enabled: true,
1935        };
1936
1937        // Content before heading - should NOT be claimed as fixable
1938        let content = "Intro text.\n\n## Heading\n\nMore.\n";
1939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940        let warnings = rule.check(&ctx).unwrap();
1941        assert_eq!(warnings.len(), 1);
1942        assert!(
1943            warnings[0].fix.is_none(),
1944            "Document with content before heading should not be claimed as fixable"
1945        );
1946    }
1947
1948    // ── Phase 1 (Case C): HTML blocks treated as preamble ──────────────────────
1949
1950    #[test]
1951    fn test_fix_html_block_before_heading_is_now_fixable() {
1952        use crate::rule::Rule;
1953        let rule = MD041FirstLineHeading {
1954            level: 1,
1955            front_matter_title: false,
1956            front_matter_title_pattern: None,
1957            fix_enabled: true,
1958        };
1959
1960        // HTML block (badges div) before the real heading – was unfixable before Phase 1
1961        let content = "<div>\n  Some HTML\n</div>\n\n# My Document\n\nContent.\n";
1962        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1963
1964        let warnings = rule.check(&ctx).unwrap();
1965        assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
1966        assert!(
1967            warnings[0].fix.is_some(),
1968            "Should be fixable: heading exists after HTML block preamble"
1969        );
1970
1971        let fixed = rule.fix(&ctx).unwrap();
1972        assert!(
1973            fixed.starts_with("# My Document\n"),
1974            "Heading should be moved to the top, got: {fixed}"
1975        );
1976    }
1977
1978    #[test]
1979    fn test_fix_html_block_wrong_level_before_heading() {
1980        use crate::rule::Rule;
1981        let rule = MD041FirstLineHeading {
1982            level: 1,
1983            front_matter_title: false,
1984            front_matter_title_pattern: None,
1985            fix_enabled: true,
1986        };
1987
1988        let content = "<div>\n  badge\n</div>\n\n## Wrong Level\n\nContent.\n";
1989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1990        let fixed = rule.fix(&ctx).unwrap();
1991        assert!(
1992            fixed.starts_with("# Wrong Level\n"),
1993            "Heading should be fixed to level 1 and moved to top, got: {fixed}"
1994        );
1995    }
1996
1997    // ── Phase 2 (Case A): PromotePlainText ──────────────────────────────────────
1998
1999    #[test]
2000    fn test_fix_promote_plain_text_title() {
2001        use crate::rule::Rule;
2002        let rule = MD041FirstLineHeading {
2003            level: 1,
2004            front_matter_title: false,
2005            front_matter_title_pattern: None,
2006            fix_enabled: true,
2007        };
2008
2009        let content = "My Project\n\nSome content.\n";
2010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011
2012        let warnings = rule.check(&ctx).unwrap();
2013        assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2014        assert!(
2015            warnings[0].fix.is_some(),
2016            "Should be fixable: first line is a title candidate"
2017        );
2018
2019        let fixed = rule.fix(&ctx).unwrap();
2020        assert_eq!(
2021            fixed, "# My Project\n\nSome content.\n",
2022            "Title line should be promoted to heading"
2023        );
2024    }
2025
2026    #[test]
2027    fn test_fix_promote_plain_text_title_with_front_matter() {
2028        use crate::rule::Rule;
2029        let rule = MD041FirstLineHeading {
2030            level: 1,
2031            front_matter_title: false,
2032            front_matter_title_pattern: None,
2033            fix_enabled: true,
2034        };
2035
2036        let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2037        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038        let fixed = rule.fix(&ctx).unwrap();
2039        assert!(
2040            fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2041            "Title should be promoted and placed right after front matter, got: {fixed}"
2042        );
2043    }
2044
2045    #[test]
2046    fn test_fix_no_promote_ends_with_period() {
2047        use crate::rule::Rule;
2048        let rule = MD041FirstLineHeading {
2049            level: 1,
2050            front_matter_title: false,
2051            front_matter_title_pattern: None,
2052            fix_enabled: true,
2053        };
2054
2055        // Sentence-ending punctuation → NOT a title candidate
2056        let content = "This is a sentence.\n\nContent.\n";
2057        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2058        let fixed = rule.fix(&ctx).unwrap();
2059        assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2060
2061        let warnings = rule.check(&ctx).unwrap();
2062        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2063    }
2064
2065    #[test]
2066    fn test_fix_no_promote_ends_with_colon() {
2067        use crate::rule::Rule;
2068        let rule = MD041FirstLineHeading {
2069            level: 1,
2070            front_matter_title: false,
2071            front_matter_title_pattern: None,
2072            fix_enabled: true,
2073        };
2074
2075        let content = "Note:\n\nContent.\n";
2076        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2077        let fixed = rule.fix(&ctx).unwrap();
2078        assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2079    }
2080
2081    #[test]
2082    fn test_fix_no_promote_if_too_long() {
2083        use crate::rule::Rule;
2084        let rule = MD041FirstLineHeading {
2085            level: 1,
2086            front_matter_title: false,
2087            front_matter_title_pattern: None,
2088            fix_enabled: true,
2089        };
2090
2091        // >80 chars → not a title candidate
2092        let long_line = "A".repeat(81);
2093        let content = format!("{long_line}\n\nContent.\n");
2094        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2095        let fixed = rule.fix(&ctx).unwrap();
2096        assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2097    }
2098
2099    #[test]
2100    fn test_fix_no_promote_if_no_blank_after() {
2101        use crate::rule::Rule;
2102        let rule = MD041FirstLineHeading {
2103            level: 1,
2104            front_matter_title: false,
2105            front_matter_title_pattern: None,
2106            fix_enabled: true,
2107        };
2108
2109        // No blank line after potential title → NOT a title candidate
2110        let content = "My Project\nImmediately continues.\n\nContent.\n";
2111        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112        let fixed = rule.fix(&ctx).unwrap();
2113        assert_eq!(fixed, content, "Line without following blank should not be promoted");
2114    }
2115
2116    #[test]
2117    fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2118        use crate::rule::Rule;
2119        let rule = MD041FirstLineHeading {
2120            level: 1,
2121            front_matter_title: false,
2122            front_matter_title_pattern: None,
2123            fix_enabled: true,
2124        };
2125
2126        // Title candidate exists but so does a heading later → can't safely fix
2127        // (the title candidate is content before the heading)
2128        let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2129        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2130        let fixed = rule.fix(&ctx).unwrap();
2131        assert_eq!(
2132            fixed, content,
2133            "Should not fix when title candidate exists before a heading"
2134        );
2135
2136        let warnings = rule.check(&ctx).unwrap();
2137        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2138    }
2139
2140    #[test]
2141    fn test_fix_promote_title_at_eof_no_trailing_newline() {
2142        use crate::rule::Rule;
2143        let rule = MD041FirstLineHeading {
2144            level: 1,
2145            front_matter_title: false,
2146            front_matter_title_pattern: None,
2147            fix_enabled: true,
2148        };
2149
2150        // Single title line at EOF with no trailing newline
2151        let content = "My Project";
2152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2153        let fixed = rule.fix(&ctx).unwrap();
2154        assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2155    }
2156
2157    // ── Phase 3 (Case B): InsertDerived ─────────────────────────────────────────
2158
2159    #[test]
2160    fn test_fix_insert_derived_directive_only_document() {
2161        use crate::rule::Rule;
2162        use std::path::PathBuf;
2163        let rule = MD041FirstLineHeading {
2164            level: 1,
2165            front_matter_title: false,
2166            front_matter_title_pattern: None,
2167            fix_enabled: true,
2168        };
2169
2170        // Document with only a note admonition and no heading
2171        // (LintContext constructed with a source file path for title derivation)
2172        let content = "!!! note\n    This is a note.\n";
2173        let ctx = LintContext::new(
2174            content,
2175            crate::config::MarkdownFlavor::MkDocs,
2176            Some(PathBuf::from("setup-guide.md")),
2177        );
2178
2179        let can_fix = rule.can_fix(&ctx);
2180        assert!(can_fix, "Directive-only document with source file should be fixable");
2181
2182        let fixed = rule.fix(&ctx).unwrap();
2183        assert!(
2184            fixed.starts_with("# Setup Guide\n"),
2185            "Should insert derived heading, got: {fixed}"
2186        );
2187    }
2188
2189    #[test]
2190    fn test_fix_no_insert_derived_without_source_file() {
2191        use crate::rule::Rule;
2192        let rule = MD041FirstLineHeading {
2193            level: 1,
2194            front_matter_title: false,
2195            front_matter_title_pattern: None,
2196            fix_enabled: true,
2197        };
2198
2199        // No source_file → derive_title returns None → InsertDerived unavailable
2200        let content = "!!! note\n    This is a note.\n";
2201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2202        let fixed = rule.fix(&ctx).unwrap();
2203        assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2204    }
2205
2206    #[test]
2207    fn test_fix_no_insert_derived_when_has_real_content() {
2208        use crate::rule::Rule;
2209        use std::path::PathBuf;
2210        let rule = MD041FirstLineHeading {
2211            level: 1,
2212            front_matter_title: false,
2213            front_matter_title_pattern: None,
2214            fix_enabled: true,
2215        };
2216
2217        // Document has real paragraph content in addition to directive blocks
2218        let content = "!!! note\n    A note.\n\nSome paragraph text.\n";
2219        let ctx = LintContext::new(
2220            content,
2221            crate::config::MarkdownFlavor::MkDocs,
2222            Some(PathBuf::from("guide.md")),
2223        );
2224        let fixed = rule.fix(&ctx).unwrap();
2225        assert_eq!(
2226            fixed, content,
2227            "Should not insert derived heading when real content is present"
2228        );
2229    }
2230
2231    #[test]
2232    fn test_derive_title_converts_kebab_case() {
2233        use std::path::PathBuf;
2234        let ctx = LintContext::new(
2235            "",
2236            crate::config::MarkdownFlavor::Standard,
2237            Some(PathBuf::from("my-setup-guide.md")),
2238        );
2239        let title = MD041FirstLineHeading::derive_title(&ctx);
2240        assert_eq!(title, Some("My Setup Guide".to_string()));
2241    }
2242
2243    #[test]
2244    fn test_derive_title_converts_underscores() {
2245        use std::path::PathBuf;
2246        let ctx = LintContext::new(
2247            "",
2248            crate::config::MarkdownFlavor::Standard,
2249            Some(PathBuf::from("api_reference.md")),
2250        );
2251        let title = MD041FirstLineHeading::derive_title(&ctx);
2252        assert_eq!(title, Some("Api Reference".to_string()));
2253    }
2254
2255    #[test]
2256    fn test_derive_title_none_without_source_file() {
2257        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2258        let title = MD041FirstLineHeading::derive_title(&ctx);
2259        assert_eq!(title, None);
2260    }
2261
2262    #[test]
2263    fn test_derive_title_index_file_uses_parent_dir() {
2264        use std::path::PathBuf;
2265        let ctx = LintContext::new(
2266            "",
2267            crate::config::MarkdownFlavor::Standard,
2268            Some(PathBuf::from("docs/getting-started/index.md")),
2269        );
2270        let title = MD041FirstLineHeading::derive_title(&ctx);
2271        assert_eq!(title, Some("Getting Started".to_string()));
2272    }
2273
2274    #[test]
2275    fn test_derive_title_readme_file_uses_parent_dir() {
2276        use std::path::PathBuf;
2277        let ctx = LintContext::new(
2278            "",
2279            crate::config::MarkdownFlavor::Standard,
2280            Some(PathBuf::from("my-project/README.md")),
2281        );
2282        let title = MD041FirstLineHeading::derive_title(&ctx);
2283        assert_eq!(title, Some("My Project".to_string()));
2284    }
2285
2286    #[test]
2287    fn test_derive_title_index_without_parent_returns_none() {
2288        use std::path::PathBuf;
2289        // Root-level index.md has no meaningful parent — "Index" is not a useful title
2290        let ctx = LintContext::new(
2291            "",
2292            crate::config::MarkdownFlavor::Standard,
2293            Some(PathBuf::from("index.md")),
2294        );
2295        let title = MD041FirstLineHeading::derive_title(&ctx);
2296        assert_eq!(title, None);
2297    }
2298
2299    #[test]
2300    fn test_derive_title_readme_without_parent_returns_none() {
2301        use std::path::PathBuf;
2302        let ctx = LintContext::new(
2303            "",
2304            crate::config::MarkdownFlavor::Standard,
2305            Some(PathBuf::from("README.md")),
2306        );
2307        let title = MD041FirstLineHeading::derive_title(&ctx);
2308        assert_eq!(title, None);
2309    }
2310
2311    #[test]
2312    fn test_derive_title_readme_case_insensitive() {
2313        use std::path::PathBuf;
2314        // Lowercase readme.md should also use parent dir
2315        let ctx = LintContext::new(
2316            "",
2317            crate::config::MarkdownFlavor::Standard,
2318            Some(PathBuf::from("docs/api/readme.md")),
2319        );
2320        let title = MD041FirstLineHeading::derive_title(&ctx);
2321        assert_eq!(title, Some("Api".to_string()));
2322    }
2323
2324    #[test]
2325    fn test_is_title_candidate_basic() {
2326        assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2327        assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2328        assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2329    }
2330
2331    #[test]
2332    fn test_is_title_candidate_rejects_sentence_punctuation() {
2333        assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2334        assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2335        assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2336        assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2337        assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2338    }
2339
2340    #[test]
2341    fn test_is_title_candidate_rejects_when_no_blank_after() {
2342        assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2343    }
2344
2345    #[test]
2346    fn test_is_title_candidate_rejects_long_lines() {
2347        let long = "A".repeat(81);
2348        assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2349        // 80 chars is the boundary – exactly 80 is OK
2350        let ok = "A".repeat(80);
2351        assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2352    }
2353
2354    #[test]
2355    fn test_is_title_candidate_rejects_structural_markdown() {
2356        assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2357        assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2358        assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2359        assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2360    }
2361
2362    #[test]
2363    fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2364        // Verify that the fix replacement for plain-text-to-heading promotion is
2365        // non-empty, so applying the fix does not delete the line.
2366        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2367        // Title candidate: short text, no trailing punctuation, followed by blank line
2368        let content = "My Document Title\n\nMore content follows.";
2369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2370        let warnings = rule.check(&ctx).unwrap();
2371        assert_eq!(warnings.len(), 1);
2372        let fix = warnings[0]
2373            .fix
2374            .as_ref()
2375            .expect("Fix should be present for promotable text");
2376        assert!(
2377            !fix.replacement.is_empty(),
2378            "Fix replacement must not be empty — applying it directly must produce valid output"
2379        );
2380        assert!(
2381            fix.replacement.starts_with("# "),
2382            "Fix replacement should be a level-1 heading, got: {:?}",
2383            fix.replacement
2384        );
2385        assert_eq!(fix.replacement, "# My Document Title");
2386    }
2387
2388    #[test]
2389    fn test_fix_replacement_not_empty_for_releveling() {
2390        // When the first line is a heading at the wrong level, the Fix should
2391        // contain the correctly-leveled heading, not an empty string.
2392        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2393        let content = "## Wrong Level\n\nContent.";
2394        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395        let warnings = rule.check(&ctx).unwrap();
2396        assert_eq!(warnings.len(), 1);
2397        let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2398        assert!(
2399            !fix.replacement.is_empty(),
2400            "Fix replacement must not be empty for releveling"
2401        );
2402        assert_eq!(fix.replacement, "# Wrong Level");
2403    }
2404
2405    #[test]
2406    fn test_fix_replacement_applied_produces_valid_output() {
2407        // Verify that applying the Fix from check() produces the same result as fix()
2408        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2409        // Title candidate: short, no trailing punctuation, followed by blank line
2410        let content = "My Document\n\nMore content.";
2411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2412
2413        let warnings = rule.check(&ctx).unwrap();
2414        assert_eq!(warnings.len(), 1);
2415        let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2416
2417        // Apply Fix directly (like LSP would)
2418        let mut patched = content.to_string();
2419        patched.replace_range(fix.range.clone(), &fix.replacement);
2420
2421        // Apply via fix() method
2422        let fixed = rule.fix(&ctx).unwrap();
2423
2424        assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2425    }
2426
2427    #[test]
2428    fn test_mdx_disable_on_line_1_no_heading() {
2429        // The exact user scenario from issue #538:
2430        // MDX disable comment on line 1, NO heading anywhere.
2431        // The disable is the ONLY reason MD041 should not fire.
2432        let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2433        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2434
2435        // check() should produce a warning on line 2 (<Note> is the first content line)
2436        let rule = MD041FirstLineHeading::default();
2437        let warnings = rule.check(&ctx).unwrap();
2438        // The rule itself produces a warning, but the engine filters it via inline config.
2439        // MD041's check() doesn't filter inline config itself — the engine does.
2440        // What matters is that the warning is on line 2 (not line 1), so the engine
2441        // can see the disable is active at line 2 and suppress it.
2442        if !warnings.is_empty() {
2443            assert_eq!(
2444                warnings[0].line, 2,
2445                "Warning must be on line 2 (first content line after MDX comment), not line 1"
2446            );
2447        }
2448    }
2449
2450    #[test]
2451    fn test_mdx_disable_fix_returns_unchanged() {
2452        // fix() should return content unchanged when MDX disable is active
2453        let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2455        let rule = MD041FirstLineHeading {
2456            fix_enabled: true,
2457            ..MD041FirstLineHeading::default()
2458        };
2459        let result = rule.fix(&ctx).unwrap();
2460        assert_eq!(
2461            result, content,
2462            "fix() should not modify content when MD041 is disabled via MDX comment"
2463        );
2464    }
2465
2466    #[test]
2467    fn test_mdx_comment_without_disable_heading_on_next_line() {
2468        let rule = MD041FirstLineHeading::default();
2469
2470        // MDX comment (not a disable directive) on line 1, heading on line 2
2471        let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2473        let result = rule.check(&ctx).unwrap();
2474        assert!(
2475            result.is_empty(),
2476            "MDX comment is preamble; heading on next line should satisfy MD041"
2477        );
2478    }
2479
2480    #[test]
2481    fn test_mdx_comment_without_heading_triggers_warning() {
2482        let rule = MD041FirstLineHeading::default();
2483
2484        // MDX comment on line 1, non-heading content on line 2
2485        let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2487        let result = rule.check(&ctx).unwrap();
2488        assert_eq!(
2489            result.len(),
2490            1,
2491            "MDX comment followed by non-heading should trigger MD041"
2492        );
2493        assert_eq!(
2494            result[0].line, 2,
2495            "Warning should be on line 2 (the first content line after MDX comment)"
2496        );
2497    }
2498
2499    #[test]
2500    fn test_multiline_mdx_comment_followed_by_heading() {
2501        let rule = MD041FirstLineHeading::default();
2502
2503        // Multi-line MDX comment followed by heading
2504        let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
2505        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2506        let result = rule.check(&ctx).unwrap();
2507        assert!(
2508            result.is_empty(),
2509            "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
2510        );
2511    }
2512
2513    #[test]
2514    fn test_html_comment_still_works_as_preamble_regression() {
2515        let rule = MD041FirstLineHeading::default();
2516
2517        // Plain HTML comment on line 1, heading on line 2
2518        let content = "<!-- Some comment -->\n# My Document\n\nContent.";
2519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2520        let result = rule.check(&ctx).unwrap();
2521        assert!(
2522            result.is_empty(),
2523            "HTML comment should still be treated as preamble (regression test)"
2524        );
2525    }
2526}