Skip to main content

rumdl_lib/rules/md041_first_line_heading/
mod.rs

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