Skip to main content

rumdl_lib/rules/md041_first_line_heading/
mod.rs

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