Skip to main content

rumdl_lib/rules/
md041_first_line_heading.rs

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