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 allow_preamble: bool,
24    pub fix_enabled: bool,
25}
26
27impl Default for MD041FirstLineHeading {
28    fn default() -> Self {
29        Self {
30            level: 1,
31            front_matter_title: true,
32            front_matter_title_pattern: None,
33            allow_preamble: false,
34            fix_enabled: false,
35        }
36    }
37}
38
39/// How to make this document compliant with MD041 (internal helper)
40enum FixPlan {
41    /// Move an existing heading to the top (after front matter), optionally releveling it.
42    MoveOrRelevel {
43        front_matter_end_idx: usize,
44        /// 0-indexed line the heading is recorded on.
45        heading_idx: usize,
46        /// 0-indexed first line holding the heading text, which differs from
47        /// `heading_idx` for a setext heading whose paragraph spans lines.
48        first_idx: usize,
49        is_setext: bool,
50        current_level: usize,
51        needs_level_fix: bool,
52    },
53    /// Promote the first plain-text title line to a level-N heading, moving it to the top.
54    PromotePlainText {
55        front_matter_end_idx: usize,
56        title_line_idx: usize,
57        title_text: String,
58    },
59    /// Insert a heading derived from the source filename at the top of the document.
60    /// Used when the document contains only directive blocks and no heading or title line.
61    InsertDerived {
62        front_matter_end_idx: usize,
63        derived_title: String,
64    },
65    /// Rewrite an existing heading to the required level, leaving it where it is.
66    /// Used when preamble is allowed: moving the heading to the top would delete the
67    /// preamble that the configuration exists to permit.
68    RelevelInPlace {
69        /// 0-indexed line the heading is recorded on.
70        heading_idx: usize,
71        /// 0-indexed first line holding the heading text, which differs from
72        /// `heading_idx` for a setext heading whose paragraph spans lines.
73        first_idx: usize,
74        is_setext: bool,
75        current_level: usize,
76    },
77}
78
79impl MD041FirstLineHeading {
80    pub fn new(level: usize, front_matter_title: bool) -> Self {
81        Self {
82            level,
83            front_matter_title,
84            front_matter_title_pattern: None,
85            allow_preamble: false,
86            fix_enabled: false,
87        }
88    }
89
90    pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
91        Self::with_pattern_from(level, front_matter_title, pattern, fix_enabled, false)
92    }
93
94    /// [`Self::with_pattern`], told whether a message about the pattern may quote it.
95    /// See [`crate::rule_config_serde::compile_config_regex`].
96    fn with_pattern_from(
97        level: usize,
98        front_matter_title: bool,
99        pattern: Option<String>,
100        fix_enabled: bool,
101        values_withheld: bool,
102    ) -> Self {
103        let front_matter_title_pattern = pattern.and_then(|p| {
104            crate::rule_config_serde::compile_config_regex(&p, "MD041", "front-matter-title-pattern", values_withheld)
105        });
106
107        Self {
108            level,
109            front_matter_title,
110            front_matter_title_pattern,
111            allow_preamble: false,
112            fix_enabled,
113        }
114    }
115
116    /// Allow content before the document's first heading.
117    pub fn with_allow_preamble(mut self, allow_preamble: bool) -> Self {
118        self.allow_preamble = allow_preamble;
119        self
120    }
121
122    fn has_front_matter_title(&self, content: &str) -> bool {
123        if !self.front_matter_title {
124            return false;
125        }
126
127        // If we have a custom pattern, use it to search front matter content
128        if let Some(ref pattern) = self.front_matter_title_pattern {
129            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
130            for line in front_matter_lines {
131                if pattern.is_match(line) {
132                    return true;
133                }
134            }
135            return false;
136        }
137
138        // Default behavior: check for "title:" field
139        FrontMatterUtils::has_front_matter_field(content, "title:")
140    }
141
142    /// Check if a line is a non-content token that should be skipped
143    fn is_non_content_line(line: &str) -> bool {
144        let trimmed = line.trim();
145
146        // Skip reference definitions
147        if trimmed.starts_with('[') && trimmed.contains("]: ") {
148            return true;
149        }
150
151        // Skip abbreviation definitions
152        if trimmed.starts_with('*') && trimmed.contains("]: ") {
153            return true;
154        }
155
156        // Skip badge/shield images - common pattern at top of READMEs
157        // Matches: ![badge](url) or [![badge](url)](url)
158        if Self::is_badge_image_line(trimmed) {
159            return true;
160        }
161
162        false
163    }
164
165    /// Only comments and whitespace are preamble; visible prose beside an
166    /// inline comment still counts as document content.
167    fn is_mdx_comment_only_line(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
168        let line = &ctx.lines[idx];
169        if !line.in_mdx_comment || line.is_setext_heading_text || ctx.heading_on_line(idx + 1).is_some() {
170            return false;
171        }
172        let prefix_len = line.blockquote.as_ref().map_or(0, |quote| quote.prefix.len());
173        let start = line.byte_offset + prefix_len;
174        let end = line.byte_offset + line.byte_len;
175        let ranges = ctx.mdx_comment_ranges();
176        let first = ranges.partition_point(|&(_, range_end)| range_end <= start);
177        let mut cursor = start;
178        for &(range_start, range_end) in &ranges[first..] {
179            if range_start >= end {
180                break;
181            }
182            let gap_end = range_start.max(cursor).min(end);
183            if !ctx.content[cursor..gap_end].trim().is_empty() {
184                return false;
185            }
186            cursor = range_end.max(cursor).min(end);
187        }
188        ctx.content[cursor..end].trim().is_empty()
189    }
190
191    /// Find the first content line index (0-indexed) in the document.
192    ///
193    /// Skips front matter, blank lines, HTML/MDX comments, ESM blocks,
194    /// kramdown extensions, MkDocs anchors, reference definitions, and badges.
195    /// Used by both check() and fix() to ensure consistent behavior.
196    fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
197        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
198
199        let filtered = ctx
200            .filtered_lines()
201            .skip_front_matter()
202            .skip_esm_blocks()
203            .skip_html_comments();
204
205        for filtered_line in filtered {
206            let idx = filtered_line.line_num - 1;
207            let line_info = &ctx.lines[idx];
208
209            if line_info.is_blank || line_info.is_kramdown_block_ial || Self::is_mdx_comment_only_line(ctx, idx) {
210                continue;
211            }
212
213            let line_content = filtered_line.content;
214            if ctx.flavor == crate::config::MarkdownFlavor::GhAw
215                && !line_info.in_code_block
216                && crate::utils::gh_aw::is_control_line(line_content)
217            {
218                continue;
219            }
220            if is_mkdocs && is_mkdocs_anchor_line(line_content) {
221                continue;
222            }
223            if Self::is_non_content_line(line_content) {
224                continue;
225            }
226            return Some(idx);
227        }
228        None
229    }
230
231    /// The heading whose text covers line `idx` (0-indexed), with the index of
232    /// the line it is recorded on, or `None` when the line holds no heading text.
233    ///
234    /// A setext heading is recorded on the last line of its text, so a line
235    /// inside the paragraph an underline ends carries no record of its own. The
236    /// covered line need not be the heading's first: the badge lines the rule
237    /// passes over can open the same paragraph.
238    fn heading_covering<'a>(
239        ctx: &'a crate::lint_context::LintContext,
240        idx: usize,
241    ) -> Option<(usize, &'a crate::lint_context::HeadingInfo)> {
242        let line_info = ctx.lines.get(idx)?;
243        if let Some(heading) = line_info.heading.as_deref() {
244            return Some((idx, heading));
245        }
246        if !line_info.is_setext_heading_text {
247            return None;
248        }
249        ctx.lines[idx..]
250            .iter()
251            .enumerate()
252            .take_while(|(_, li)| li.is_setext_heading_text)
253            .find_map(|(offset, li)| li.heading.as_deref().map(|heading| (idx + offset, heading)))
254    }
255
256    fn line_starts_in_literal_code(ctx: &crate::lint_context::LintContext, idx: usize) -> bool {
257        ctx.lines.get(idx).is_some_and(|line| {
258            let marker_offset = line.byte_offset + line.indent;
259            ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
260        })
261    }
262
263    /// Find the index (0-indexed) of the document's first top-level heading.
264    ///
265    /// Used when preamble is allowed, where the rule judges the level of the first
266    /// heading rather than requiring the document to open with one. Only top-level
267    /// headings count: one inside a list, blockquote, directive block or HTML element
268    /// is that container's content, so the scan passes over it and keeps looking.
269    /// Returns `None` for a document with no top-level heading, which the rule then
270    /// has nothing to judge.
271    fn first_top_level_heading_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
272        for (idx, line_info) in ctx.lines.iter().enumerate() {
273            if line_info.is_blank
274                || line_info.in_front_matter
275                || line_info.in_code_block
276                || line_info.in_html_comment
277                || Self::is_mdx_comment_only_line(ctx, idx)
278                || line_info.in_math_block
279                || Self::line_starts_in_literal_code(ctx, idx)
280            {
281                continue;
282            }
283
284            let in_container = line_info.in_list_block
285                || line_info.blockquote.is_some()
286                || line_info.in_admonition
287                || line_info.in_content_tab
288                || line_info.in_pandoc_div
289                || line_info.in_pymdown_block
290                || line_info.in_kramdown_extension_block;
291            if in_container {
292                continue;
293            }
294
295            if let Some(heading) = line_info.heading.as_deref() {
296                let marker_idx =
297                    idx + usize::from(matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2));
298                if Self::line_starts_in_literal_code(ctx, marker_idx) {
299                    continue;
300                }
301                // A setext heading's text is the whole paragraph its underline
302                // ends, so the heading starts on the first of those lines.
303                return Some(idx + 1 - heading.text_lines);
304            }
305
306            // An HTML heading counts only where its block begins. An `<h1>` on a later
307            // line of the block is nested in whatever element opened it.
308            let continues_html_block = idx > 0 && line_info.in_html_block && ctx.lines[idx - 1].in_html_block;
309            if !continues_html_block && (1..=6).any(|level| Self::is_html_heading(ctx, idx, level)) {
310                return Some(idx);
311            }
312        }
313        None
314    }
315
316    /// The line this rule judges, which is also the line it reports on and the line
317    /// whose inline directives govern it.
318    ///
319    /// Allowing preamble moves the subject of the rule from the document's first
320    /// content line to its first heading, so a document with no heading has nothing
321    /// to judge.
322    fn checked_line_idx(&self, ctx: &crate::lint_context::LintContext) -> Option<usize> {
323        if self.allow_preamble {
324            Self::first_top_level_heading_idx(ctx)
325        } else {
326            Self::first_content_line_idx(ctx)
327        }
328    }
329
330    /// Check if a line consists only of badge/shield images
331    /// Common patterns:
332    /// - `![badge](url)`
333    /// - `[![badge](url)](url)` (linked badge)
334    /// - Multiple badges on one line
335    fn is_badge_image_line(line: &str) -> bool {
336        if line.is_empty() {
337            return false;
338        }
339
340        // Must start with image syntax
341        if !line.starts_with('!') && !line.starts_with('[') {
342            return false;
343        }
344
345        // Check if line contains only image/link patterns and whitespace
346        let mut remaining = line;
347        while !remaining.is_empty() {
348            remaining = remaining.trim_start();
349            if remaining.is_empty() {
350                break;
351            }
352
353            // Linked image: [![alt](img-url)](link-url)
354            if remaining.starts_with("[![") {
355                if let Some(end) = Self::find_linked_image_end(remaining) {
356                    remaining = &remaining[end..];
357                    continue;
358                }
359                return false;
360            }
361
362            // Simple image: ![alt](url)
363            if remaining.starts_with("![") {
364                if let Some(end) = Self::find_image_end(remaining) {
365                    remaining = &remaining[end..];
366                    continue;
367                }
368                return false;
369            }
370
371            // Not an image pattern
372            return false;
373        }
374
375        true
376    }
377
378    /// Find the end of an image pattern ![alt](url)
379    fn find_image_end(s: &str) -> Option<usize> {
380        if !s.starts_with("![") {
381            return None;
382        }
383        // Find ]( after ![
384        let alt_end = s[2..].find("](")?;
385        let paren_start = 2 + alt_end + 2; // Position after ](
386        // Find closing )
387        let paren_end = s[paren_start..].find(')')?;
388        Some(paren_start + paren_end + 1)
389    }
390
391    /// Find the end of a linked image pattern [![alt](img-url)](link-url)
392    fn find_linked_image_end(s: &str) -> Option<usize> {
393        if !s.starts_with("[![") {
394            return None;
395        }
396        // Find the inner image first
397        let inner_end = Self::find_image_end(&s[1..])?;
398        let after_inner = 1 + inner_end;
399        // Should be followed by ](url)
400        if !s[after_inner..].starts_with("](") {
401            return None;
402        }
403        let link_start = after_inner + 2;
404        let link_end = s[link_start..].find(')')?;
405        Some(link_start + link_end + 1)
406    }
407
408    /// Fix a heading line to use the specified level
409    fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
410        let trimmed = line.trim_start();
411
412        // ATX-style heading (# Heading)
413        if trimmed.starts_with('#') {
414            let hashes = "#".repeat(target_level);
415            // Find where the content starts (after # and optional space)
416            let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
417            let after_hashes = &trimmed[content_start..];
418            let content = after_hashes.trim_start();
419
420            // Preserve leading whitespace from original line
421            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
422            format!("{leading_ws}{hashes} {content}")
423        } else {
424            // Setext-style heading - convert to ATX
425            // The underline would be on the next line, so we just convert the text line
426            let hashes = "#".repeat(target_level);
427            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
428            format!("{leading_ws}{hashes} {trimmed}")
429        }
430    }
431
432    /// The heading recorded at `heading_idx` rewritten as one ATX line at the
433    /// configured level.
434    ///
435    /// A setext heading's text is the whole paragraph its underline ends, so the
436    /// whole span collapses into a single line built from the joined text and
437    /// indented like the first of those lines.
438    fn heading_as_atx(
439        &self,
440        ctx: &crate::lint_context::LintContext,
441        heading_idx: usize,
442        first_idx: usize,
443        current_level: usize,
444    ) -> String {
445        let first_line = ctx.lines[first_idx].content(ctx.content);
446        match ctx.lines[heading_idx].heading.as_deref() {
447            Some(heading) if heading.text_lines > 1 => {
448                let leading_ws: String = first_line.chars().take_while(|c| c.is_whitespace()).collect();
449                format!("{leading_ws}{} {}", "#".repeat(self.level), heading.raw_text)
450            }
451            _ => self.fix_heading_level(first_line, current_level, self.level),
452        }
453    }
454
455    /// Returns true if the text of a paragraph line looks like a document title
456    /// rather than a body paragraph.
457    ///
458    /// Criteria:
459    /// - Non-empty and at most 80 characters (characters, not bytes, so a
460    ///   multibyte title is judged by its visible length)
461    /// - Does not end with sentence-ending punctuation (. ? ! : ;)
462    /// - Followed by a blank line or EOF (visually separated from body text)
463    ///
464    /// Whether the line IS paragraph text is the parser's call, made by
465    /// `is_promotable_line` before this is consulted.
466    fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
467        if text.is_empty() {
468            return false;
469        }
470
471        if !next_is_blank_or_eof {
472            return false;
473        }
474
475        if text.chars().count() > 80 {
476            return false;
477        }
478
479        let last_char = text.chars().next_back().unwrap_or(' ');
480        !matches!(last_char, '.' | '?' | '!' | ':' | ';')
481    }
482
483    /// Whether a line is paragraph text that `# ` could turn into a heading.
484    ///
485    /// Any other line opens or continues a construct, and prefixing it would
486    /// rewrite that construct instead of naming the document: `>Quote` is a
487    /// blockquote, `1. Introduction` a list, `***` a thematic break, and a
488    /// fence line or an indented line is code. The parser has already
489    /// classified every line, so its verdict decides rather than a second
490    /// reading of the text; a list item or blockquote line is prose to the
491    /// paragraph predicate and is excluded here by name.
492    fn is_promotable_line(line_info: &crate::lint_context::LineInfo) -> bool {
493        line_info.is_paragraph_context() && line_info.list_item.is_none() && line_info.blockquote.is_none()
494    }
495
496    /// Derive a title string from the source file's stem.
497    /// Converts kebab-case and underscores to Title Case words.
498    /// Returns None when no source file is available.
499    fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
500        let path = ctx.source_file()?;
501        let stem = path.file_stem().and_then(|s| s.to_str())?;
502
503        // For index/readme files, use the parent directory name instead.
504        // If no parent directory exists, return None — "Index" or "README" are not useful titles.
505        let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
506            path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
507        } else {
508            stem
509        };
510
511        let title: String = effective_stem
512            .split(['-', '_'])
513            .filter(|w| !w.is_empty())
514            .map(|word| {
515                let mut chars = word.chars();
516                match chars.next() {
517                    None => String::new(),
518                    Some(first) => {
519                        let upper: String = first.to_uppercase().collect();
520                        upper + chars.as_str()
521                    }
522                }
523            })
524            .collect::<Vec<_>>()
525            .join(" ");
526
527        if title.is_empty() { None } else { Some(title) }
528    }
529
530    /// Check if a line is an HTML heading using the centralized HTML parser
531    fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
532        // Check for single-line HTML heading using regex (fast path)
533        let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
534        if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
535            && let Some(h_level) = captures.get(1)
536            && h_level.as_str().parse::<usize>().unwrap_or(0) == level
537        {
538            return true;
539        }
540
541        // Use centralized HTML parser for multi-line headings
542        let html_tags = ctx.html_tags();
543        let target_tag = format!("h{level}");
544
545        // Find opening tag on first line
546        let opening_index = html_tags.iter().position(|tag| {
547            tag.line == first_line_idx + 1 // HtmlTag uses 1-indexed lines
548                && tag.tag_name == target_tag
549                && !tag.is_closing
550        });
551
552        let Some(open_idx) = opening_index else {
553            return false;
554        };
555
556        // Walk HTML tags to find the corresponding closing tag, allowing arbitrary nesting depth.
557        // This avoids brittle line-count heuristics and handles long headings with nested content.
558        let mut depth = 1usize;
559        for tag in html_tags.iter().skip(open_idx + 1) {
560            // Ignore tags that appear before the first heading line (possible when multiple tags share a line)
561            if tag.line <= first_line_idx + 1 {
562                continue;
563            }
564
565            if tag.tag_name == target_tag {
566                if tag.is_closing {
567                    depth -= 1;
568                    if depth == 0 {
569                        return true;
570                    }
571                } else if !tag.is_self_closing {
572                    depth += 1;
573                }
574            }
575        }
576
577        false
578    }
579
580    /// Analyze the document to determine how (if at all) it can be auto-fixed.
581    fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
582        if ctx.lines.is_empty() {
583            return None;
584        }
585
586        // Preamble is allowed, so the only safe repair is releveling the heading where
587        // it stands. Every other plan promotes something to the top of the document,
588        // which would remove the preamble this configuration permits.
589        if self.allow_preamble {
590            let first_idx = Self::first_top_level_heading_idx(ctx)?;
591            let (heading_idx, heading) = Self::heading_covering(ctx, first_idx)?;
592            if heading.level as usize == self.level {
593                return None;
594            }
595            return Some(FixPlan::RelevelInPlace {
596                heading_idx,
597                first_idx,
598                is_setext: matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
599                current_level: heading.level as usize,
600            });
601        }
602
603        // Find front matter end (handles YAML, TOML, JSON, malformed)
604        let mut front_matter_end_idx = 0;
605        for line_info in &ctx.lines {
606            if line_info.in_front_matter {
607                front_matter_end_idx += 1;
608            } else {
609                break;
610            }
611        }
612
613        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
614        let is_gh_aw = ctx.flavor == crate::config::MarkdownFlavor::GhAw;
615
616        // (idx, first_idx, is_setext, current_level) of the first ATX/Setext heading found
617        let mut found_heading: Option<(usize, usize, bool, usize)> = None;
618        // First non-preamble, non-directive line that looks like a title
619        let mut first_title_candidate: Option<(usize, String)> = None;
620        // True once we see a non-preamble, non-directive line that is NOT a title candidate
621        let mut found_non_title_content = false;
622        // True when any non-directive, non-preamble line is encountered
623        let mut saw_non_directive_content = false;
624        let mut saw_gh_aw_directive = false;
625
626        'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
627            let line_content = line_info.content(ctx.content);
628            let trimmed = line_content.trim();
629
630            if is_gh_aw && !line_info.in_code_block && crate::utils::gh_aw::is_control_line(line_content) {
631                saw_gh_aw_directive = true;
632                continue;
633            }
634
635            // Preamble: invisible/structural tokens that don't count as content
636            let is_preamble = trimmed.is_empty()
637                || line_info.in_html_comment
638                || Self::is_mdx_comment_only_line(ctx, idx)
639                || line_info.in_html_block
640                || Self::is_non_content_line(line_content)
641                || (is_mkdocs && is_mkdocs_anchor_line(line_content))
642                || line_info.in_kramdown_extension_block
643                || line_info.is_kramdown_block_ial;
644
645            if is_preamble {
646                continue;
647            }
648
649            // Directive blocks (admonitions, content tabs, Quarto/Pandoc divs, PyMdown Blocks)
650            // are structural containers, not narrative content.
651            let is_directive_block = line_info.in_admonition
652                || line_info.in_content_tab
653                || line_info.in_pandoc_div
654                || line_info.is_div_marker
655                || line_info.in_pymdown_block;
656
657            if !is_directive_block {
658                saw_non_directive_content = true;
659            }
660
661            // A setext heading is recorded on the last line of its text, so the
662            // lines above it inside the same paragraph are heading text and not
663            // the plain-text title candidate they would otherwise look like.
664            if line_info.is_setext_heading_text && line_info.heading.is_none() {
665                continue;
666            }
667
668            // ATX or Setext heading (HTML headings cannot be moved/converted)
669            if let Some(heading) = &line_info.heading {
670                let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
671                found_heading = Some((idx, idx + 1 - heading.text_lines, is_setext, heading.level as usize));
672                break 'scan;
673            }
674
675            // Track non-heading, non-directive content for PromotePlainText detection
676            if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
677                let next_is_blank_or_eof = ctx
678                    .lines
679                    .get(idx + 1)
680                    .is_none_or(|l| l.content(ctx.content).trim().is_empty());
681
682                if Self::is_promotable_line(line_info) && Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
683                    first_title_candidate = Some((idx, trimmed.to_string()));
684                } else {
685                    found_non_title_content = true;
686                }
687            }
688        }
689
690        if let Some((h_idx, first_idx, is_setext, current_level)) = found_heading {
691            // Heading exists. Can we move/relevel it?
692            // If real content or a title candidate appeared before it, the heading is not the
693            // first significant element - reordering would change document meaning.
694            if found_non_title_content || first_title_candidate.is_some() {
695                return None;
696            }
697
698            let needs_level_fix = current_level != self.level;
699
700            // gh-aw directives can import Markdown or delimit conditional
701            // content. Moving a heading across one changes workflow semantics,
702            // so the only safe repair is to relevel the heading where it is.
703            if saw_gh_aw_directive {
704                return needs_level_fix.then_some(FixPlan::RelevelInPlace {
705                    heading_idx: h_idx,
706                    first_idx,
707                    is_setext,
708                    current_level,
709                });
710            }
711            let needs_move = first_idx > front_matter_end_idx;
712
713            if needs_level_fix || needs_move {
714                return Some(FixPlan::MoveOrRelevel {
715                    front_matter_end_idx,
716                    heading_idx: h_idx,
717                    first_idx,
718                    is_setext,
719                    current_level,
720                    needs_level_fix,
721                });
722            }
723            return None; // Already at the correct position and level
724        }
725
726        // No heading found. Try to create one.
727
728        if let Some((title_idx, title_text)) = first_title_candidate {
729            if saw_gh_aw_directive {
730                return None;
731            }
732            return Some(FixPlan::PromotePlainText {
733                front_matter_end_idx,
734                title_line_idx: title_idx,
735                title_text,
736            });
737        }
738
739        // Document has no heading and no title candidate. If it contains only directive
740        // blocks (plus preamble), we can insert a heading derived from the filename.
741        if !saw_gh_aw_directive
742            && !saw_non_directive_content
743            && let Some(derived_title) = Self::derive_title(ctx)
744        {
745            return Some(FixPlan::InsertDerived {
746                front_matter_end_idx,
747                derived_title,
748            });
749        }
750
751        None
752    }
753
754    /// Determine if this document can be auto-fixed.
755    fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
756        self.fix_enabled && self.analyze_for_fix(ctx).is_some()
757    }
758}
759
760impl Rule for MD041FirstLineHeading {
761    fn name(&self) -> &'static str {
762        "MD041"
763    }
764
765    fn description(&self) -> &'static str {
766        "First line in file should be a top level heading"
767    }
768
769    /// Fixing is opt-in: adding a document title is a content decision, so the rule
770    /// reports no fix capability until `fix = true` turns it on. Once on it is only
771    /// ever conditional, because a document with no heading to promote or move has
772    /// nothing the fixer can safely do.
773    fn fix_capability(&self) -> FixCapability {
774        if self.fix_enabled {
775            FixCapability::ConditionallyFixable
776        } else {
777            FixCapability::Unfixable
778        }
779    }
780
781    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
782        let mut warnings = Vec::new();
783
784        // Check if we should skip this file
785        if self.should_skip(ctx) {
786            return Ok(warnings);
787        }
788
789        let Some(first_line_idx) = self.checked_line_idx(ctx) else {
790            return Ok(warnings);
791        };
792
793        // Check if the first non-blank line is a heading of the required level
794        let first_line_info = &ctx.lines[first_line_idx];
795        // A setext heading is recorded on the last line of its text, so the line
796        // being judged carries a record only when it is that last line.
797        let covering_heading = Self::heading_covering(ctx, first_line_idx);
798        let is_correct_heading = if let Some((_, heading)) = covering_heading {
799            heading.level as usize == self.level
800        } else {
801            // Check for HTML heading (both single-line and multi-line)
802            Self::is_html_heading(ctx, first_line_idx, self.level)
803        };
804
805        if !is_correct_heading {
806            // Calculate precise character range for the entire first line
807            let first_line = first_line_idx + 1; // Convert to 1-indexed
808            let first_line_content = first_line_info.content(ctx.content);
809            let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
810            // A setext heading's text is the whole paragraph its underline ends,
811            // so the warning runs to the text on the last of those lines.
812            let (end_line, end_col) = match covering_heading.filter(|(last_idx, _)| *last_idx > first_line_idx) {
813                Some((last_idx, _)) => {
814                    let last_content = ctx.lines[last_idx].content(ctx.content);
815                    (last_idx + 1, last_content.trim_end().chars().count() + 1)
816                }
817                None => (end_line, end_col),
818            };
819
820            // Compute the actual replacement so that LSP quick-fix can apply it
821            // directly without calling fix(). For simple cases (releveling,
822            // promote-plain-text at the first content line), we use a targeted
823            // range. For complex cases (moving headings, inserting derived
824            // titles), we replace the entire document via fix().
825            let fix = if self.can_fix(ctx) {
826                self.analyze_for_fix(ctx).and_then(|plan| {
827                    let range_start = first_line_info.byte_offset;
828                    let range_end = range_start + first_line_info.byte_len;
829                    match &plan {
830                        // A setext heading spans its underline and, when its
831                        // paragraph runs on, further text lines, so it cannot be
832                        // rewritten through a range covering one line.
833                        FixPlan::MoveOrRelevel {
834                            heading_idx,
835                            first_idx,
836                            current_level,
837                            needs_level_fix,
838                            is_setext,
839                            ..
840                        } if *first_idx == first_line_idx && !*is_setext => {
841                            // Heading is already at the correct position, just needs releveling
842                            let heading_line = ctx.lines[*heading_idx].content(ctx.content);
843                            let replacement = if *needs_level_fix {
844                                self.fix_heading_level(heading_line, *current_level, self.level)
845                            } else {
846                                heading_line.to_string()
847                            };
848                            Some(Fix::new(range_start..range_end, replacement))
849                        }
850                        FixPlan::RelevelInPlace {
851                            heading_idx,
852                            first_idx,
853                            current_level,
854                            is_setext,
855                        } if *first_idx == first_line_idx && !*is_setext => {
856                            let replacement = self.fix_heading_level(
857                                ctx.lines[*heading_idx].content(ctx.content),
858                                *current_level,
859                                self.level,
860                            );
861                            Some(Fix::new(range_start..range_end, replacement))
862                        }
863                        FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
864                            let replacement = format!(
865                                "{} {}",
866                                "#".repeat(self.level),
867                                ctx.lines[*title_line_idx].content(ctx.content).trim()
868                            );
869                            Some(Fix::new(range_start..range_end, replacement))
870                        }
871                        _ => {
872                            // Complex multi-line operations (moving headings, inserting
873                            // derived titles, promoting non-first-line text): replace
874                            // the entire document via fix().
875                            self.fix(ctx)
876                                .ok()
877                                .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
878                        }
879                    }
880                })
881            } else {
882                None
883            };
884
885            warnings.push(LintWarning {
886                rule_name: Some(self.name().to_string()),
887                line: start_line,
888                column: start_col,
889                end_line,
890                end_column: end_col,
891                message: if self.allow_preamble {
892                    format!("First heading in file should be a level {} heading", self.level)
893                } else {
894                    format!("First line in file should be a level {} heading", self.level)
895                },
896                severity: Severity::Warning,
897                fix,
898            });
899        }
900        Ok(warnings)
901    }
902
903    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
904        if !self.fix_enabled {
905            return Ok(ctx.content.to_string());
906        }
907
908        if self.should_skip(ctx) {
909            return Ok(ctx.content.to_string());
910        }
911
912        // Respect inline disable comments, resolving the line the same way check()
913        // does so a directive suppresses exactly the warning it appears to suppress.
914        let checked_line = self.checked_line_idx(ctx).map_or(1, |i| i + 1);
915        if ctx.inline_config().is_rule_disabled(self.name(), checked_line) {
916            return Ok(ctx.content.to_string());
917        }
918
919        let Some(plan) = self.analyze_for_fix(ctx) else {
920            return Ok(ctx.content.to_string());
921        };
922
923        let lines = ctx.raw_lines();
924
925        let mut result = String::new();
926        let preserve_trailing_newline = ctx.content.ends_with('\n');
927
928        match plan {
929            FixPlan::MoveOrRelevel {
930                front_matter_end_idx,
931                heading_idx,
932                first_idx,
933                is_setext,
934                current_level,
935                needs_level_fix,
936            } => {
937                let fixed_heading = if needs_level_fix || is_setext {
938                    self.heading_as_atx(ctx, heading_idx, first_idx, current_level)
939                } else {
940                    ctx.lines[heading_idx].content(ctx.content).to_string()
941                };
942
943                for line in lines.iter().take(front_matter_end_idx) {
944                    result.push_str(line);
945                    result.push('\n');
946                }
947                result.push_str(&fixed_heading);
948                result.push('\n');
949                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
950                    // Every text line of the heading moved into the one line
951                    // just written, and a setext underline has nothing left to
952                    // underline.
953                    if (first_idx..=heading_idx).contains(&idx) {
954                        continue;
955                    }
956                    if is_setext && idx == heading_idx + 1 {
957                        continue;
958                    }
959                    result.push_str(line);
960                    result.push('\n');
961                }
962            }
963
964            FixPlan::PromotePlainText {
965                front_matter_end_idx,
966                title_line_idx,
967                title_text,
968            } => {
969                let hashes = "#".repeat(self.level);
970                let new_heading = format!("{hashes} {title_text}");
971
972                for line in lines.iter().take(front_matter_end_idx) {
973                    result.push_str(line);
974                    result.push('\n');
975                }
976                result.push_str(&new_heading);
977                result.push('\n');
978                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
979                    if idx == title_line_idx {
980                        continue;
981                    }
982                    result.push_str(line);
983                    result.push('\n');
984                }
985            }
986
987            FixPlan::RelevelInPlace {
988                heading_idx,
989                first_idx,
990                is_setext,
991                current_level,
992            } => {
993                for (idx, line) in lines.iter().enumerate() {
994                    if idx == first_idx {
995                        result.push_str(&self.heading_as_atx(ctx, heading_idx, first_idx, current_level));
996                        result.push('\n');
997                        continue;
998                    }
999                    // The remaining text lines and the underline are gone:
1000                    // releveling rewrites a setext heading as one ATX line.
1001                    if idx > first_idx && idx <= heading_idx {
1002                        continue;
1003                    }
1004                    if is_setext && idx == heading_idx + 1 {
1005                        continue;
1006                    }
1007                    result.push_str(line);
1008                    result.push('\n');
1009                }
1010            }
1011
1012            FixPlan::InsertDerived {
1013                front_matter_end_idx,
1014                derived_title,
1015            } => {
1016                let hashes = "#".repeat(self.level);
1017                let new_heading = format!("{hashes} {derived_title}");
1018
1019                for line in lines.iter().take(front_matter_end_idx) {
1020                    result.push_str(line);
1021                    result.push('\n');
1022                }
1023                result.push_str(&new_heading);
1024                result.push('\n');
1025                result.push('\n');
1026                for line in lines.iter().skip(front_matter_end_idx) {
1027                    result.push_str(line);
1028                    result.push('\n');
1029                }
1030            }
1031        }
1032
1033        if !preserve_trailing_newline && result.ends_with('\n') {
1034            result.pop();
1035        }
1036
1037        Ok(result)
1038    }
1039
1040    /// Check if this rule should be skipped
1041    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1042        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
1043        // These files are composition/routing metadata, not standalone content.
1044        // Example: A file containing only "{{#include ../../README.md}}" is a
1045        // pointer to content, not content itself, and shouldn't need a heading.
1046        let only_directives = !ctx.content.is_empty()
1047            && ctx.lines.iter().filter(|line| !line.is_blank).all(|line| {
1048                let t = line.content(ctx.content).trim();
1049                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
1050                (if ctx.flavor == crate::config::MarkdownFlavor::GhAw {
1051                    !line.in_code_block && crate::utils::gh_aw::is_control_line(t)
1052                } else {
1053                    t.starts_with("{{#") && t.ends_with("}}")
1054                })
1055                        // HTML comments often accompany directives
1056                        || (t.starts_with("<!--") && t.ends_with("-->"))
1057            });
1058
1059        ctx.content.is_empty()
1060            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
1061            || only_directives
1062    }
1063
1064    fn as_any(&self) -> &dyn std::any::Any {
1065        self
1066    }
1067
1068    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1069    where
1070        Self: Sized,
1071    {
1072        // Load config using serde with kebab-case support
1073        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
1074
1075        let use_front_matter = !md041_config.front_matter_title.is_empty();
1076
1077        Box::new(
1078            MD041FirstLineHeading::with_pattern_from(
1079                md041_config.level.as_usize(),
1080                use_front_matter,
1081                md041_config.front_matter_title_pattern,
1082                md041_config.fix,
1083                config.withheld_rule_values.contains("MD041"),
1084            )
1085            .with_allow_preamble(md041_config.allow_preamble),
1086        )
1087    }
1088
1089    crate::impl_rule_config_sections!(MD041Config);
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use crate::lint_context::LintContext;
1096
1097    #[test]
1098    fn test_visible_prose_beside_mdx_comments_is_not_preamble() {
1099        let rule = MD041FirstLineHeading {
1100            fix_enabled: true,
1101            ..MD041FirstLineHeading::default()
1102        };
1103        for prose in [
1104            "Visible prose {/* c */}",
1105            "{/* c */} Visible prose",
1106            "{/* c */} Visible prose {/* d */}",
1107        ] {
1108            let source = format!("{prose}\n\n# actual heading\n");
1109            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1110            let warnings = rule.check(&ctx).unwrap();
1111            assert_eq!(warnings.len(), 1, "visible content precedes the heading: {source}");
1112            assert_eq!(warnings[0].line, 1);
1113            assert_eq!(
1114                rule.fix(&ctx).unwrap(),
1115                source,
1116                "narrative content must not be reordered"
1117            );
1118            let allowed = rule.clone().with_allow_preamble(true);
1119            assert!(allowed.check(&ctx).unwrap().is_empty());
1120            assert_eq!(allowed.fix(&ctx).unwrap(), source);
1121        }
1122    }
1123
1124    #[test]
1125    fn test_comment_only_mdx_lines_still_count_as_preamble() {
1126        let rule = MD041FirstLineHeading::default();
1127        for preamble in [
1128            "{/* c */}",
1129            "  {/* c */}  ",
1130            "{/* c */} {/* d */}",
1131            "> {/* c */}",
1132            "{/*\n# literal heading\n*/}",
1133        ] {
1134            let source = format!("{preamble}\n\n# actual heading\n");
1135            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
1136            assert!(
1137                rule.check(&ctx).unwrap().is_empty(),
1138                "no visible prose in preamble: {source}"
1139            );
1140        }
1141    }
1142
1143    #[test]
1144    fn test_mdx_inline_comments_do_not_hide_a_correct_first_heading() {
1145        let rule = MD041FirstLineHeading {
1146            fix_enabled: true,
1147            ..MD041FirstLineHeading::default()
1148        };
1149        for source in [
1150            "# hello {/* c */}\n\nVisible prose.\n",
1151            "hello {/* c */}\n===\n\nVisible prose.\n",
1152        ] {
1153            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1154            assert!(rule.check(&ctx).unwrap().is_empty(), "valid first heading: {source}");
1155            assert_eq!(rule.fix(&ctx).unwrap(), source, "no title may be added: {source}");
1156        }
1157        let source = "{/* preamble */}\n\n# hello {/* c */}\n\nVisible prose.\n";
1158        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1159        assert!(rule.check(&ctx).unwrap().is_empty());
1160        let rule = rule.with_allow_preamble(true);
1161        assert_eq!(rule.fix(&ctx).unwrap(), source);
1162    }
1163
1164    #[test]
1165    fn test_mdx_inline_comment_heading_is_judged_when_preamble_is_allowed() {
1166        let rule = MD041FirstLineHeading::default().with_allow_preamble(true);
1167        for source in [
1168            "## second level {/* c */}\n\n# later first level\n",
1169            "second level {/* c */}\n---\n\n# later first level\n",
1170        ] {
1171            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1172            let warnings = rule.check(&ctx).unwrap();
1173            assert_eq!(warnings.len(), 1, "the first heading still counts: {source}");
1174            assert_eq!(warnings[0].line, 1);
1175        }
1176    }
1177
1178    #[test]
1179    fn test_mdx_flow_comment_headings_remain_preamble_for_md041() {
1180        let rule = MD041FirstLineHeading::default();
1181        let source = "{/*\n# hidden heading\n*/}\n\n# visible title\n";
1182        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
1183        assert!(rule.check(&ctx).unwrap().is_empty());
1184    }
1185
1186    #[test]
1187    fn test_first_line_is_heading_correct_level() {
1188        let rule = MD041FirstLineHeading::default();
1189
1190        // First line is a level 1 heading (should pass)
1191        let content = "# My Document\n\nSome content here.";
1192        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193        let result = rule.check(&ctx).unwrap();
1194        assert!(
1195            result.is_empty(),
1196            "Expected no warnings when first line is a level 1 heading"
1197        );
1198    }
1199
1200    #[test]
1201    fn test_first_line_is_heading_wrong_level() {
1202        let rule = MD041FirstLineHeading::default();
1203
1204        // First line is a level 2 heading (should fail with level 1 requirement)
1205        let content = "## My Document\n\nSome content here.";
1206        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207        let result = rule.check(&ctx).unwrap();
1208        assert_eq!(result.len(), 1);
1209        assert_eq!(result[0].line, 1);
1210        assert!(result[0].message.contains("level 1 heading"));
1211    }
1212
1213    #[test]
1214    fn test_first_line_not_heading() {
1215        let rule = MD041FirstLineHeading::default();
1216
1217        // First line is plain text (should fail)
1218        let content = "This is not a heading\n\n# This is a heading";
1219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220        let result = rule.check(&ctx).unwrap();
1221        assert_eq!(result.len(), 1);
1222        assert_eq!(result[0].line, 1);
1223        assert!(result[0].message.contains("level 1 heading"));
1224    }
1225
1226    #[test]
1227    fn test_empty_lines_before_heading() {
1228        let rule = MD041FirstLineHeading::default();
1229
1230        // Empty lines before first heading (should pass - rule skips empty lines)
1231        let content = "\n\n# My Document\n\nSome content.";
1232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233        let result = rule.check(&ctx).unwrap();
1234        assert!(
1235            result.is_empty(),
1236            "Expected no warnings when empty lines precede a valid heading"
1237        );
1238
1239        // Empty lines before non-heading content (should fail)
1240        let content = "\n\nNot a heading\n\nSome content.";
1241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242        let result = rule.check(&ctx).unwrap();
1243        assert_eq!(result.len(), 1);
1244        assert_eq!(result[0].line, 3); // First non-empty line
1245        assert!(result[0].message.contains("level 1 heading"));
1246    }
1247
1248    #[test]
1249    fn test_front_matter_with_title() {
1250        let rule = MD041FirstLineHeading::new(1, true);
1251
1252        // Front matter with title field (should pass)
1253        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
1254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255        let result = rule.check(&ctx).unwrap();
1256        assert!(
1257            result.is_empty(),
1258            "Expected no warnings when front matter has title field"
1259        );
1260    }
1261
1262    #[test]
1263    fn test_front_matter_without_title() {
1264        let rule = MD041FirstLineHeading::new(1, true);
1265
1266        // Front matter without title field (should fail)
1267        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
1268        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269        let result = rule.check(&ctx).unwrap();
1270        assert_eq!(result.len(), 1);
1271        assert_eq!(result[0].line, 6); // First content line after front matter
1272    }
1273
1274    #[test]
1275    fn test_front_matter_disabled() {
1276        let rule = MD041FirstLineHeading::new(1, false);
1277
1278        // Front matter with title field but front_matter_title is false (should fail)
1279        let content = "---\ntitle: My Document\n---\n\nSome content here.";
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_eq!(result[0].line, 5); // First content line after front matter
1284    }
1285
1286    #[test]
1287    fn test_html_comments_before_heading() {
1288        let rule = MD041FirstLineHeading::default();
1289
1290        // HTML comment before heading (should pass - comments are skipped, issue #155)
1291        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
1292        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293        let result = rule.check(&ctx).unwrap();
1294        assert!(
1295            result.is_empty(),
1296            "HTML comments should be skipped when checking for first heading"
1297        );
1298    }
1299
1300    #[test]
1301    fn test_multiline_html_comment_before_heading() {
1302        let rule = MD041FirstLineHeading::default();
1303
1304        // Multi-line HTML comment before heading (should pass - issue #155)
1305        let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
1306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307        let result = rule.check(&ctx).unwrap();
1308        assert!(
1309            result.is_empty(),
1310            "Multi-line HTML comments should be skipped when checking for first heading"
1311        );
1312    }
1313
1314    #[test]
1315    fn test_html_comment_with_blank_lines_before_heading() {
1316        let rule = MD041FirstLineHeading::default();
1317
1318        // HTML comment with blank lines before heading (should pass - issue #155)
1319        let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
1320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321        let result = rule.check(&ctx).unwrap();
1322        assert!(
1323            result.is_empty(),
1324            "HTML comments with blank lines should be skipped when checking for first heading"
1325        );
1326    }
1327
1328    #[test]
1329    fn test_html_comment_before_html_heading() {
1330        let rule = MD041FirstLineHeading::default();
1331
1332        // HTML comment before HTML heading (should pass - issue #155)
1333        let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
1334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335        let result = rule.check(&ctx).unwrap();
1336        assert!(
1337            result.is_empty(),
1338            "HTML comments should be skipped before HTML headings"
1339        );
1340    }
1341
1342    #[test]
1343    fn test_document_with_only_html_comments() {
1344        let rule = MD041FirstLineHeading::default();
1345
1346        // Document with only HTML comments (should pass - no warnings for comment-only files)
1347        let content = "<!-- This is a comment -->\n<!-- Another comment -->";
1348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349        let result = rule.check(&ctx).unwrap();
1350        assert!(
1351            result.is_empty(),
1352            "Documents with only HTML comments should not trigger MD041"
1353        );
1354    }
1355
1356    #[test]
1357    fn test_html_comment_followed_by_non_heading() {
1358        let rule = MD041FirstLineHeading::default();
1359
1360        // HTML comment followed by non-heading content (should still fail - issue #155)
1361        let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
1362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1363        let result = rule.check(&ctx).unwrap();
1364        assert_eq!(
1365            result.len(),
1366            1,
1367            "HTML comment followed by non-heading should still trigger MD041"
1368        );
1369        assert_eq!(
1370            result[0].line, 2,
1371            "Warning should be on the first non-comment, non-heading line"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_multiple_html_comments_before_heading() {
1377        let rule = MD041FirstLineHeading::default();
1378
1379        // Multiple HTML comments before heading (should pass - issue #155)
1380        let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
1381        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382        let result = rule.check(&ctx).unwrap();
1383        assert!(
1384            result.is_empty(),
1385            "Multiple HTML comments should all be skipped before heading"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_html_comment_with_wrong_level_heading() {
1391        let rule = MD041FirstLineHeading::default();
1392
1393        // HTML comment followed by wrong-level heading (should fail - issue #155)
1394        let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396        let result = rule.check(&ctx).unwrap();
1397        assert_eq!(
1398            result.len(),
1399            1,
1400            "HTML comment followed by wrong-level heading should still trigger MD041"
1401        );
1402        assert!(
1403            result[0].message.contains("level 1 heading"),
1404            "Should require level 1 heading"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_html_comment_mixed_with_reference_definitions() {
1410        let rule = MD041FirstLineHeading::default();
1411
1412        // HTML comment mixed with reference definitions before heading (should pass - issue #155)
1413        let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415        let result = rule.check(&ctx).unwrap();
1416        assert!(
1417            result.is_empty(),
1418            "HTML comments and reference definitions should both be skipped before heading"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_html_comment_after_front_matter() {
1424        let rule = MD041FirstLineHeading::default();
1425
1426        // HTML comment after front matter, before heading (should pass - issue #155)
1427        let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429        let result = rule.check(&ctx).unwrap();
1430        assert!(
1431            result.is_empty(),
1432            "HTML comments after front matter should be skipped before heading"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_html_comment_not_at_start_should_not_affect_rule() {
1438        let rule = MD041FirstLineHeading::default();
1439
1440        // HTML comment in middle of document should not affect MD041 check
1441        let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
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            "HTML comments in middle of document should not affect MD041 (only first content matters)"
1447        );
1448    }
1449
1450    #[test]
1451    fn test_multiline_html_comment_followed_by_non_heading() {
1452        let rule = MD041FirstLineHeading::default();
1453
1454        // Multi-line HTML comment followed by non-heading (should still fail - issue #155)
1455        let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457        let result = rule.check(&ctx).unwrap();
1458        assert_eq!(
1459            result.len(),
1460            1,
1461            "Multi-line HTML comment followed by non-heading should still trigger MD041"
1462        );
1463        assert_eq!(
1464            result[0].line, 5,
1465            "Warning should be on the first non-comment, non-heading line"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_different_heading_levels() {
1471        // Test with level 2 requirement
1472        let rule = MD041FirstLineHeading::new(2, false);
1473
1474        let content = "## Second Level Heading\n\nContent.";
1475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476        let result = rule.check(&ctx).unwrap();
1477        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1478
1479        // Wrong level
1480        let content = "# First Level Heading\n\nContent.";
1481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482        let result = rule.check(&ctx).unwrap();
1483        assert_eq!(result.len(), 1);
1484        assert!(result[0].message.contains("level 2 heading"));
1485    }
1486
1487    #[test]
1488    fn test_setext_headings() {
1489        let rule = MD041FirstLineHeading::default();
1490
1491        // Setext style level 1 heading (should pass)
1492        let content = "My Document\n===========\n\nContent.";
1493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494        let result = rule.check(&ctx).unwrap();
1495        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1496
1497        // Setext style level 2 heading (should fail with level 1 requirement)
1498        let content = "My Document\n-----------\n\nContent.";
1499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500        let result = rule.check(&ctx).unwrap();
1501        assert_eq!(result.len(), 1);
1502        assert!(result[0].message.contains("level 1 heading"));
1503    }
1504
1505    #[test]
1506    fn test_empty_document() {
1507        let rule = MD041FirstLineHeading::default();
1508
1509        // Empty document (should pass - no warnings)
1510        let content = "";
1511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512        let result = rule.check(&ctx).unwrap();
1513        assert!(result.is_empty(), "Expected no warnings for empty document");
1514    }
1515
1516    #[test]
1517    fn test_whitespace_only_document() {
1518        let rule = MD041FirstLineHeading::default();
1519
1520        // Document with only whitespace (should pass - no warnings)
1521        let content = "   \n\n   \t\n";
1522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1523        let result = rule.check(&ctx).unwrap();
1524        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1525    }
1526
1527    #[test]
1528    fn test_front_matter_then_whitespace() {
1529        let rule = MD041FirstLineHeading::default();
1530
1531        // Front matter followed by only whitespace (should pass - no warnings)
1532        let content = "---\ntitle: Test\n---\n\n   \n\n";
1533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534        let result = rule.check(&ctx).unwrap();
1535        assert!(
1536            result.is_empty(),
1537            "Expected no warnings when no content after front matter"
1538        );
1539    }
1540
1541    #[test]
1542    fn test_multiple_front_matter_types() {
1543        let rule = MD041FirstLineHeading::new(1, true);
1544
1545        // TOML front matter with title (should pass - title satisfies heading requirement)
1546        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548        let result = rule.check(&ctx).unwrap();
1549        assert!(
1550            result.is_empty(),
1551            "Expected no warnings for TOML front matter with title"
1552        );
1553
1554        // JSON front matter with title (should pass)
1555        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1556        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557        let result = rule.check(&ctx).unwrap();
1558        assert!(
1559            result.is_empty(),
1560            "Expected no warnings for JSON front matter with title"
1561        );
1562
1563        // YAML front matter with title field (standard case)
1564        let content = "---\ntitle: My Document\n---\n\nContent.";
1565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1566        let result = rule.check(&ctx).unwrap();
1567        assert!(
1568            result.is_empty(),
1569            "Expected no warnings for YAML front matter with title"
1570        );
1571    }
1572
1573    #[test]
1574    fn test_toml_front_matter_with_heading() {
1575        let rule = MD041FirstLineHeading::default();
1576
1577        // TOML front matter followed by correct heading (should pass)
1578        let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1579        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580        let result = rule.check(&ctx).unwrap();
1581        assert!(
1582            result.is_empty(),
1583            "Expected no warnings when heading follows TOML front matter"
1584        );
1585    }
1586
1587    #[test]
1588    fn test_toml_front_matter_without_title_no_heading() {
1589        let rule = MD041FirstLineHeading::new(1, true);
1590
1591        // TOML front matter without title, no heading (should warn)
1592        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1593        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594        let result = rule.check(&ctx).unwrap();
1595        assert_eq!(result.len(), 1);
1596        assert_eq!(result[0].line, 6);
1597    }
1598
1599    #[test]
1600    fn test_toml_front_matter_level_2_heading() {
1601        // Reproduces the exact scenario from issue #427
1602        let rule = MD041FirstLineHeading::new(2, true);
1603
1604        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1606        let result = rule.check(&ctx).unwrap();
1607        assert!(
1608            result.is_empty(),
1609            "Issue #427: TOML front matter with title and correct heading level should not warn"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1615        // Reproduces the exact config shape from issue #427
1616        let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1617
1618        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620        let result = rule.check(&ctx).unwrap();
1621        assert!(
1622            result.is_empty(),
1623            "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1624        );
1625    }
1626
1627    #[test]
1628    fn test_json_front_matter_with_heading() {
1629        let rule = MD041FirstLineHeading::default();
1630
1631        // JSON front matter followed by correct heading
1632        let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634        let result = rule.check(&ctx).unwrap();
1635        assert!(
1636            result.is_empty(),
1637            "Expected no warnings when heading follows JSON front matter"
1638        );
1639    }
1640
1641    #[test]
1642    fn test_malformed_front_matter() {
1643        let rule = MD041FirstLineHeading::new(1, true);
1644
1645        // Malformed front matter with title
1646        let content = "- --\ntitle: My Document\n- --\n\nContent.";
1647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648        let result = rule.check(&ctx).unwrap();
1649        assert!(
1650            result.is_empty(),
1651            "Expected no warnings for malformed front matter with title"
1652        );
1653    }
1654
1655    #[test]
1656    fn test_front_matter_with_heading() {
1657        let rule = MD041FirstLineHeading::default();
1658
1659        // Front matter without title field followed by correct heading
1660        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1661        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662        let result = rule.check(&ctx).unwrap();
1663        assert!(
1664            result.is_empty(),
1665            "Expected no warnings when first line after front matter is correct heading"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_no_fix_suggestion() {
1671        let rule = MD041FirstLineHeading::default();
1672
1673        // Check that NO fix suggestion is provided (MD041 is now detection-only)
1674        let content = "Not a heading\n\nContent.";
1675        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676        let result = rule.check(&ctx).unwrap();
1677        assert_eq!(result.len(), 1);
1678        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1679    }
1680
1681    #[test]
1682    fn test_complex_document_structure() {
1683        let rule = MD041FirstLineHeading::default();
1684
1685        // Complex document with various elements - HTML comment should be skipped (issue #155)
1686        let content =
1687            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689        let result = rule.check(&ctx).unwrap();
1690        assert!(
1691            result.is_empty(),
1692            "HTML comments should be skipped, so first heading after comment should be valid"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_heading_with_special_characters() {
1698        let rule = MD041FirstLineHeading::default();
1699
1700        // Heading with special characters and formatting
1701        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703        let result = rule.check(&ctx).unwrap();
1704        assert!(
1705            result.is_empty(),
1706            "Expected no warnings for heading with inline formatting"
1707        );
1708    }
1709
1710    #[test]
1711    fn test_level_configuration() {
1712        // Test various level configurations
1713        for level in 1..=6 {
1714            let rule = MD041FirstLineHeading::new(level, false);
1715
1716            // Correct level
1717            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1718            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1719            let result = rule.check(&ctx).unwrap();
1720            assert!(
1721                result.is_empty(),
1722                "Expected no warnings for correct level {level} heading"
1723            );
1724
1725            // Wrong level
1726            let wrong_level = if level == 1 { 2 } else { 1 };
1727            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1728            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1729            let result = rule.check(&ctx).unwrap();
1730            assert_eq!(result.len(), 1);
1731            assert!(result[0].message.contains(&format!("level {level} heading")));
1732        }
1733    }
1734
1735    #[test]
1736    fn test_issue_152_multiline_html_heading() {
1737        let rule = MD041FirstLineHeading::default();
1738
1739        // Multi-line HTML h1 heading (should pass - issue #152)
1740        let content = "<h1>\nSome text\n</h1>";
1741        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742        let result = rule.check(&ctx).unwrap();
1743        assert!(
1744            result.is_empty(),
1745            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1746        );
1747    }
1748
1749    #[test]
1750    fn test_multiline_html_heading_with_attributes() {
1751        let rule = MD041FirstLineHeading::default();
1752
1753        // Multi-line HTML heading with attributes
1754        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1756        let result = rule.check(&ctx).unwrap();
1757        assert!(
1758            result.is_empty(),
1759            "Multi-line HTML heading with attributes should be recognized"
1760        );
1761    }
1762
1763    #[test]
1764    fn test_multiline_html_heading_wrong_level() {
1765        let rule = MD041FirstLineHeading::default();
1766
1767        // Multi-line HTML h2 heading (should fail with level 1 requirement)
1768        let content = "<h2>\nSome text\n</h2>";
1769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1770        let result = rule.check(&ctx).unwrap();
1771        assert_eq!(result.len(), 1);
1772        assert!(result[0].message.contains("level 1 heading"));
1773    }
1774
1775    #[test]
1776    fn test_multiline_html_heading_with_content_after() {
1777        let rule = MD041FirstLineHeading::default();
1778
1779        // Multi-line HTML heading followed by content
1780        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1781        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1782        let result = rule.check(&ctx).unwrap();
1783        assert!(
1784            result.is_empty(),
1785            "Multi-line HTML heading followed by content should be valid"
1786        );
1787    }
1788
1789    #[test]
1790    fn test_multiline_html_heading_incomplete() {
1791        let rule = MD041FirstLineHeading::default();
1792
1793        // Incomplete multi-line HTML heading (missing closing tag)
1794        let content = "<h1>\nSome text\n\nMore content without closing tag";
1795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796        let result = rule.check(&ctx).unwrap();
1797        assert_eq!(result.len(), 1);
1798        assert!(result[0].message.contains("level 1 heading"));
1799    }
1800
1801    #[test]
1802    fn test_singleline_html_heading_still_works() {
1803        let rule = MD041FirstLineHeading::default();
1804
1805        // Single-line HTML heading should still work
1806        let content = "<h1>My Document</h1>\n\nContent.";
1807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1808        let result = rule.check(&ctx).unwrap();
1809        assert!(
1810            result.is_empty(),
1811            "Single-line HTML headings should still be recognized"
1812        );
1813    }
1814
1815    #[test]
1816    fn test_multiline_html_heading_with_nested_tags() {
1817        let rule = MD041FirstLineHeading::default();
1818
1819        // Multi-line HTML heading with nested tags
1820        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1821        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822        let result = rule.check(&ctx).unwrap();
1823        assert!(
1824            result.is_empty(),
1825            "Multi-line HTML heading with nested tags should be recognized"
1826        );
1827    }
1828
1829    #[test]
1830    fn test_multiline_html_heading_various_levels() {
1831        // Test multi-line headings at different levels
1832        for level in 1..=6 {
1833            let rule = MD041FirstLineHeading::new(level, false);
1834
1835            // Correct level multi-line
1836            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1837            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1838            let result = rule.check(&ctx).unwrap();
1839            assert!(
1840                result.is_empty(),
1841                "Multi-line HTML heading at level {level} should be recognized"
1842            );
1843
1844            // Wrong level multi-line
1845            let wrong_level = if level == 1 { 2 } else { 1 };
1846            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1847            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1848            let result = rule.check(&ctx).unwrap();
1849            assert_eq!(result.len(), 1);
1850            assert!(result[0].message.contains(&format!("level {level} heading")));
1851        }
1852    }
1853
1854    #[test]
1855    fn test_issue_152_nested_heading_spans_many_lines() {
1856        let rule = MD041FirstLineHeading::default();
1857
1858        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>";
1859        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860        let result = rule.check(&ctx).unwrap();
1861        assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1862    }
1863
1864    #[test]
1865    fn test_issue_152_picture_tag_heading() {
1866        let rule = MD041FirstLineHeading::default();
1867
1868        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>";
1869        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870        let result = rule.check(&ctx).unwrap();
1871        assert!(
1872            result.is_empty(),
1873            "Picture tag inside multi-line HTML heading should be recognized"
1874        );
1875    }
1876
1877    #[test]
1878    fn test_badge_images_before_heading() {
1879        let rule = MD041FirstLineHeading::default();
1880
1881        // Single badge before heading
1882        let content = "![badge](https://img.shields.io/badge/test-passing-green)\n\n# My Project";
1883        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884        let result = rule.check(&ctx).unwrap();
1885        assert!(result.is_empty(), "Badge image should be skipped");
1886
1887        // Multiple badges on one line
1888        let content = "![badge1](url1) ![badge2](url2)\n\n# My Project";
1889        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890        let result = rule.check(&ctx).unwrap();
1891        assert!(result.is_empty(), "Multiple badges should be skipped");
1892
1893        // Linked badge (clickable)
1894        let content = "[![badge](https://img.shields.io/badge/test-pass-green)](https://example.com)\n\n# My Project";
1895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896        let result = rule.check(&ctx).unwrap();
1897        assert!(result.is_empty(), "Linked badge should be skipped");
1898    }
1899
1900    #[test]
1901    fn test_multiple_badge_lines_before_heading() {
1902        let rule = MD041FirstLineHeading::default();
1903
1904        // Multiple lines of badges
1905        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";
1906        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1907        let result = rule.check(&ctx).unwrap();
1908        assert!(result.is_empty(), "Multiple badge lines should be skipped");
1909    }
1910
1911    #[test]
1912    fn test_badges_without_heading_still_warns() {
1913        let rule = MD041FirstLineHeading::default();
1914
1915        // Badges followed by paragraph (not heading)
1916        let content = "![badge](url)\n\nThis is not a heading.";
1917        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918        let result = rule.check(&ctx).unwrap();
1919        assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1920    }
1921
1922    #[test]
1923    fn test_mixed_content_not_badge_line() {
1924        let rule = MD041FirstLineHeading::default();
1925
1926        // Image with text is not a badge line
1927        let content = "![badge](url) Some text here\n\n# Heading";
1928        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1929        let result = rule.check(&ctx).unwrap();
1930        assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1931    }
1932
1933    #[test]
1934    fn test_is_badge_image_line_unit() {
1935        // Unit tests for is_badge_image_line
1936        assert!(MD041FirstLineHeading::is_badge_image_line("![badge](url)"));
1937        assert!(MD041FirstLineHeading::is_badge_image_line("[![badge](img)](link)"));
1938        assert!(MD041FirstLineHeading::is_badge_image_line("![a](b) ![c](d)"));
1939        assert!(MD041FirstLineHeading::is_badge_image_line("[![a](b)](c) [![d](e)](f)"));
1940
1941        // Not badge lines
1942        assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1943        assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1944        assert!(!MD041FirstLineHeading::is_badge_image_line("![badge](url) text"));
1945        assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1946    }
1947
1948    // Integration tests for MkDocs anchor line detection (issue #365)
1949    // Unit tests for is_mkdocs_anchor_line are in utils/mkdocs_attr_list.rs
1950
1951    #[test]
1952    fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1953        let rule = MD041FirstLineHeading::default();
1954
1955        // MkDocs anchor line before heading in MkDocs flavor (should pass)
1956        let content = "[](){ #example }\n# Title";
1957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1958        let result = rule.check(&ctx).unwrap();
1959        assert!(
1960            result.is_empty(),
1961            "MkDocs anchor line should be skipped in MkDocs flavor"
1962        );
1963    }
1964
1965    #[test]
1966    fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1967        let rule = MD041FirstLineHeading::default();
1968
1969        // MkDocs anchor line before heading in Standard flavor (should fail)
1970        let content = "[](){ #example }\n# Title";
1971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972        let result = rule.check(&ctx).unwrap();
1973        assert_eq!(
1974            result.len(),
1975            1,
1976            "MkDocs anchor line should NOT be skipped in Standard flavor"
1977        );
1978    }
1979
1980    #[test]
1981    fn test_multiple_mkdocs_anchors_before_heading() {
1982        let rule = MD041FirstLineHeading::default();
1983
1984        // Multiple MkDocs anchor lines before heading in MkDocs flavor
1985        let content = "[](){ #first }\n[](){ #second }\n# Title";
1986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1987        let result = rule.check(&ctx).unwrap();
1988        assert!(
1989            result.is_empty(),
1990            "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1991        );
1992    }
1993
1994    #[test]
1995    fn test_mkdocs_anchor_with_front_matter() {
1996        let rule = MD041FirstLineHeading::default();
1997
1998        // MkDocs anchor after front matter
1999        let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
2000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2001        let result = rule.check(&ctx).unwrap();
2002        assert!(
2003            result.is_empty(),
2004            "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
2005        );
2006    }
2007
2008    #[test]
2009    fn test_mkdocs_anchor_kramdown_style() {
2010        let rule = MD041FirstLineHeading::default();
2011
2012        // Kramdown-style with colon
2013        let content = "[](){: #anchor }\n# Title";
2014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2015        let result = rule.check(&ctx).unwrap();
2016        assert!(
2017            result.is_empty(),
2018            "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
2019        );
2020    }
2021
2022    #[test]
2023    fn test_mkdocs_anchor_without_heading_still_warns() {
2024        let rule = MD041FirstLineHeading::default();
2025
2026        // MkDocs anchor followed by non-heading content
2027        let content = "[](){ #anchor }\nThis is not a heading.";
2028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2029        let result = rule.check(&ctx).unwrap();
2030        assert_eq!(
2031            result.len(),
2032            1,
2033            "MkDocs anchor followed by non-heading should still trigger MD041"
2034        );
2035    }
2036
2037    #[test]
2038    fn test_mkdocs_anchor_with_html_comment() {
2039        let rule = MD041FirstLineHeading::default();
2040
2041        // MkDocs anchor combined with HTML comment before heading
2042        let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
2043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2044        let result = rule.check(&ctx).unwrap();
2045        assert!(
2046            result.is_empty(),
2047            "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
2048        );
2049    }
2050
2051    // Tests for auto-fix functionality (issue #359)
2052
2053    #[test]
2054    fn test_fix_disabled_by_default() {
2055        use crate::rule::Rule;
2056        let rule = MD041FirstLineHeading::default();
2057
2058        // Fix should not change content when disabled
2059        let content = "## Wrong Level\n\nContent.";
2060        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2061        let fixed = rule.fix(&ctx).unwrap();
2062        assert_eq!(fixed, content, "Fix should not change content when disabled");
2063    }
2064
2065    #[test]
2066    fn test_fix_wrong_heading_level() {
2067        use crate::rule::Rule;
2068        let rule = MD041FirstLineHeading {
2069            level: 1,
2070            front_matter_title: false,
2071            front_matter_title_pattern: None,
2072            allow_preamble: false,
2073            fix_enabled: true,
2074        };
2075
2076        // ## should become #
2077        let content = "## Wrong Level\n\nContent.\n";
2078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079        let fixed = rule.fix(&ctx).unwrap();
2080        assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
2081    }
2082
2083    #[test]
2084    fn test_fix_heading_after_preamble() {
2085        use crate::rule::Rule;
2086        let rule = MD041FirstLineHeading {
2087            level: 1,
2088            front_matter_title: false,
2089            front_matter_title_pattern: None,
2090            allow_preamble: false,
2091            fix_enabled: true,
2092        };
2093
2094        // Heading after blank lines should be moved up
2095        let content = "\n\n# Title\n\nContent.\n";
2096        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2097        let fixed = rule.fix(&ctx).unwrap();
2098        assert!(
2099            fixed.starts_with("# Title\n"),
2100            "Heading should be moved to first line, got: {fixed}"
2101        );
2102    }
2103
2104    #[test]
2105    fn test_fix_heading_after_html_comment() {
2106        use crate::rule::Rule;
2107        let rule = MD041FirstLineHeading {
2108            level: 1,
2109            front_matter_title: false,
2110            front_matter_title_pattern: None,
2111            allow_preamble: false,
2112            fix_enabled: true,
2113        };
2114
2115        // Heading after HTML comment should be moved up
2116        let content = "<!-- Comment -->\n# Title\n\nContent.\n";
2117        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2118        let fixed = rule.fix(&ctx).unwrap();
2119        assert!(
2120            fixed.starts_with("# Title\n"),
2121            "Heading should be moved above comment, got: {fixed}"
2122        );
2123    }
2124
2125    #[test]
2126    fn test_fix_heading_level_and_move() {
2127        use crate::rule::Rule;
2128        let rule = MD041FirstLineHeading {
2129            level: 1,
2130            front_matter_title: false,
2131            front_matter_title_pattern: None,
2132            allow_preamble: false,
2133            fix_enabled: true,
2134        };
2135
2136        // Heading with wrong level after preamble should be fixed and moved
2137        let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
2138        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2139        let fixed = rule.fix(&ctx).unwrap();
2140        assert!(
2141            fixed.starts_with("# Wrong Level\n"),
2142            "Heading should be fixed and moved, got: {fixed}"
2143        );
2144    }
2145
2146    #[test]
2147    fn test_fix_with_front_matter() {
2148        use crate::rule::Rule;
2149        let rule = MD041FirstLineHeading {
2150            level: 1,
2151            front_matter_title: false,
2152            front_matter_title_pattern: None,
2153            allow_preamble: false,
2154            fix_enabled: true,
2155        };
2156
2157        // Heading after front matter and preamble
2158        let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
2159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2160        let fixed = rule.fix(&ctx).unwrap();
2161        assert!(
2162            fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
2163            "Heading should be right after front matter, got: {fixed}"
2164        );
2165    }
2166
2167    #[test]
2168    fn test_fix_with_toml_front_matter() {
2169        use crate::rule::Rule;
2170        let rule = MD041FirstLineHeading {
2171            level: 1,
2172            front_matter_title: false,
2173            front_matter_title_pattern: None,
2174            allow_preamble: false,
2175            fix_enabled: true,
2176        };
2177
2178        // Heading after TOML front matter and preamble
2179        let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
2180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2181        let fixed = rule.fix(&ctx).unwrap();
2182        assert!(
2183            fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
2184            "Heading should be right after TOML front matter, got: {fixed}"
2185        );
2186    }
2187
2188    #[test]
2189    fn test_fix_cannot_fix_no_heading() {
2190        use crate::rule::Rule;
2191        let rule = MD041FirstLineHeading {
2192            level: 1,
2193            front_matter_title: false,
2194            front_matter_title_pattern: None,
2195            allow_preamble: false,
2196            fix_enabled: true,
2197        };
2198
2199        // No heading in document - cannot fix
2200        let content = "Just some text.\n\nMore text.\n";
2201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2202        let fixed = rule.fix(&ctx).unwrap();
2203        assert_eq!(fixed, content, "Should not change content when no heading exists");
2204    }
2205
2206    #[test]
2207    fn test_fix_cannot_fix_content_before_heading() {
2208        use crate::rule::Rule;
2209        let rule = MD041FirstLineHeading {
2210            level: 1,
2211            front_matter_title: false,
2212            front_matter_title_pattern: None,
2213            allow_preamble: false,
2214            fix_enabled: true,
2215        };
2216
2217        // Real content before heading - cannot safely fix
2218        let content = "Some intro text.\n\n# Title\n\nContent.\n";
2219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2220        let fixed = rule.fix(&ctx).unwrap();
2221        assert_eq!(
2222            fixed, content,
2223            "Should not change content when real content exists before heading"
2224        );
2225    }
2226
2227    #[test]
2228    fn test_fix_already_correct() {
2229        use crate::rule::Rule;
2230        let rule = MD041FirstLineHeading {
2231            level: 1,
2232            front_matter_title: false,
2233            front_matter_title_pattern: None,
2234            allow_preamble: false,
2235            fix_enabled: true,
2236        };
2237
2238        // Already correct - no changes needed
2239        let content = "# Title\n\nContent.\n";
2240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2241        let fixed = rule.fix(&ctx).unwrap();
2242        assert_eq!(fixed, content, "Should not change already correct content");
2243    }
2244
2245    #[test]
2246    fn test_fix_setext_heading_removes_underline() {
2247        use crate::rule::Rule;
2248        let rule = MD041FirstLineHeading {
2249            level: 1,
2250            front_matter_title: false,
2251            front_matter_title_pattern: None,
2252            allow_preamble: false,
2253            fix_enabled: true,
2254        };
2255
2256        // Setext heading (level 2 with --- underline)
2257        let content = "Wrong Level\n-----------\n\nContent.\n";
2258        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2259        let fixed = rule.fix(&ctx).unwrap();
2260        assert_eq!(
2261            fixed, "# Wrong Level\n\nContent.\n",
2262            "Setext heading should be converted to ATX and underline removed"
2263        );
2264    }
2265
2266    #[test]
2267    fn test_fix_setext_h1_heading() {
2268        use crate::rule::Rule;
2269        let rule = MD041FirstLineHeading {
2270            level: 1,
2271            front_matter_title: false,
2272            front_matter_title_pattern: None,
2273            allow_preamble: false,
2274            fix_enabled: true,
2275        };
2276
2277        // Setext h1 heading (=== underline) after preamble - needs move but not level fix
2278        let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
2279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2280        let fixed = rule.fix(&ctx).unwrap();
2281        assert_eq!(
2282            fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
2283            "Setext h1 should be moved and converted to ATX"
2284        );
2285    }
2286
2287    #[test]
2288    fn test_html_heading_not_claimed_fixable() {
2289        use crate::rule::Rule;
2290        let rule = MD041FirstLineHeading {
2291            level: 1,
2292            front_matter_title: false,
2293            front_matter_title_pattern: None,
2294            allow_preamble: false,
2295            fix_enabled: true,
2296        };
2297
2298        // HTML heading - should NOT be claimed as fixable (we can't convert HTML to ATX)
2299        let content = "<h2>Title</h2>\n\nContent.\n";
2300        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2301        let warnings = rule.check(&ctx).unwrap();
2302        assert_eq!(warnings.len(), 1);
2303        assert!(
2304            warnings[0].fix.is_none(),
2305            "HTML heading should not be claimed as fixable"
2306        );
2307    }
2308
2309    #[test]
2310    fn test_no_heading_not_claimed_fixable() {
2311        use crate::rule::Rule;
2312        let rule = MD041FirstLineHeading {
2313            level: 1,
2314            front_matter_title: false,
2315            front_matter_title_pattern: None,
2316            allow_preamble: false,
2317            fix_enabled: true,
2318        };
2319
2320        // No heading in document - should NOT be claimed as fixable
2321        let content = "Just some text.\n\nMore text.\n";
2322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323        let warnings = rule.check(&ctx).unwrap();
2324        assert_eq!(warnings.len(), 1);
2325        assert!(
2326            warnings[0].fix.is_none(),
2327            "Document without heading should not be claimed as fixable"
2328        );
2329    }
2330
2331    #[test]
2332    fn test_content_before_heading_not_claimed_fixable() {
2333        use crate::rule::Rule;
2334        let rule = MD041FirstLineHeading {
2335            level: 1,
2336            front_matter_title: false,
2337            front_matter_title_pattern: None,
2338            allow_preamble: false,
2339            fix_enabled: true,
2340        };
2341
2342        // Content before heading - should NOT be claimed as fixable
2343        let content = "Intro text.\n\n## Heading\n\nMore.\n";
2344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345        let warnings = rule.check(&ctx).unwrap();
2346        assert_eq!(warnings.len(), 1);
2347        assert!(
2348            warnings[0].fix.is_none(),
2349            "Document with content before heading should not be claimed as fixable"
2350        );
2351    }
2352
2353    // ── Phase 1 (Case C): HTML blocks treated as preamble ──────────────────────
2354
2355    #[test]
2356    fn test_fix_html_block_before_heading_is_now_fixable() {
2357        use crate::rule::Rule;
2358        let rule = MD041FirstLineHeading {
2359            level: 1,
2360            front_matter_title: false,
2361            front_matter_title_pattern: None,
2362            allow_preamble: false,
2363            fix_enabled: true,
2364        };
2365
2366        // HTML block (badges div) before the real heading – was unfixable before Phase 1
2367        let content = "<div>\n  Some HTML\n</div>\n\n# My Document\n\nContent.\n";
2368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369
2370        let warnings = rule.check(&ctx).unwrap();
2371        assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
2372        assert!(
2373            warnings[0].fix.is_some(),
2374            "Should be fixable: heading exists after HTML block preamble"
2375        );
2376
2377        let fixed = rule.fix(&ctx).unwrap();
2378        assert!(
2379            fixed.starts_with("# My Document\n"),
2380            "Heading should be moved to the top, got: {fixed}"
2381        );
2382    }
2383
2384    #[test]
2385    fn test_fix_html_block_wrong_level_before_heading() {
2386        use crate::rule::Rule;
2387        let rule = MD041FirstLineHeading {
2388            level: 1,
2389            front_matter_title: false,
2390            front_matter_title_pattern: None,
2391            allow_preamble: false,
2392            fix_enabled: true,
2393        };
2394
2395        let content = "<div>\n  badge\n</div>\n\n## Wrong Level\n\nContent.\n";
2396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397        let fixed = rule.fix(&ctx).unwrap();
2398        assert!(
2399            fixed.starts_with("# Wrong Level\n"),
2400            "Heading should be fixed to level 1 and moved to top, got: {fixed}"
2401        );
2402    }
2403
2404    // ── Phase 2 (Case A): PromotePlainText ──────────────────────────────────────
2405
2406    #[test]
2407    fn test_fix_promote_plain_text_title() {
2408        use crate::rule::Rule;
2409        let rule = MD041FirstLineHeading {
2410            level: 1,
2411            front_matter_title: false,
2412            front_matter_title_pattern: None,
2413            allow_preamble: false,
2414            fix_enabled: true,
2415        };
2416
2417        let content = "My Project\n\nSome content.\n";
2418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419
2420        let warnings = rule.check(&ctx).unwrap();
2421        assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2422        assert!(
2423            warnings[0].fix.is_some(),
2424            "Should be fixable: first line is a title candidate"
2425        );
2426
2427        let fixed = rule.fix(&ctx).unwrap();
2428        assert_eq!(
2429            fixed, "# My Project\n\nSome content.\n",
2430            "Title line should be promoted to heading"
2431        );
2432    }
2433
2434    #[test]
2435    fn test_fix_promote_plain_text_title_with_front_matter() {
2436        use crate::rule::Rule;
2437        let rule = MD041FirstLineHeading {
2438            level: 1,
2439            front_matter_title: false,
2440            front_matter_title_pattern: None,
2441            allow_preamble: false,
2442            fix_enabled: true,
2443        };
2444
2445        let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2447        let fixed = rule.fix(&ctx).unwrap();
2448        assert!(
2449            fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2450            "Title should be promoted and placed right after front matter, got: {fixed}"
2451        );
2452    }
2453
2454    #[test]
2455    fn test_fix_no_promote_ends_with_period() {
2456        use crate::rule::Rule;
2457        let rule = MD041FirstLineHeading {
2458            level: 1,
2459            front_matter_title: false,
2460            front_matter_title_pattern: None,
2461            allow_preamble: false,
2462            fix_enabled: true,
2463        };
2464
2465        // Sentence-ending punctuation → NOT a title candidate
2466        let content = "This is a sentence.\n\nContent.\n";
2467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2468        let fixed = rule.fix(&ctx).unwrap();
2469        assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2470
2471        let warnings = rule.check(&ctx).unwrap();
2472        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2473    }
2474
2475    #[test]
2476    fn test_fix_no_promote_ends_with_colon() {
2477        use crate::rule::Rule;
2478        let rule = MD041FirstLineHeading {
2479            level: 1,
2480            front_matter_title: false,
2481            front_matter_title_pattern: None,
2482            allow_preamble: false,
2483            fix_enabled: true,
2484        };
2485
2486        let content = "Note:\n\nContent.\n";
2487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2488        let fixed = rule.fix(&ctx).unwrap();
2489        assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2490    }
2491
2492    #[test]
2493    fn test_fix_no_promote_if_too_long() {
2494        use crate::rule::Rule;
2495        let rule = MD041FirstLineHeading {
2496            level: 1,
2497            front_matter_title: false,
2498            front_matter_title_pattern: None,
2499            allow_preamble: false,
2500            fix_enabled: true,
2501        };
2502
2503        // >80 chars → not a title candidate
2504        let long_line = "A".repeat(81);
2505        let content = format!("{long_line}\n\nContent.\n");
2506        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2507        let fixed = rule.fix(&ctx).unwrap();
2508        assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2509    }
2510
2511    #[test]
2512    fn test_fix_no_promote_ordered_list_item() {
2513        use crate::rule::Rule;
2514        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2515
2516        // An ordered list item is content the document opens with, not a title
2517        for content in [
2518            "1. Introduction\n\nBody.\n",
2519            "1) Introduction\n\nBody.\n",
2520            "123456789. Ninth step\n\nBody.\n",
2521        ] {
2522            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2523            assert!(!rule.check(&ctx).unwrap().is_empty(), "missing H1 is still reported");
2524            let fixed = rule.fix(&ctx).unwrap();
2525            assert_eq!(fixed, content, "an ordered list item must not become a heading");
2526        }
2527
2528        // Ten digits exceed CommonMark's marker length, so the line is a paragraph
2529        let content = "1234567890. Release Notes\n\nBody.\n";
2530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531        assert_eq!(rule.fix(&ctx).unwrap(), "# 1234567890. Release Notes\n\nBody.\n");
2532    }
2533
2534    /// A line the parser reads as anything but paragraph text opens a construct
2535    /// that `# ` would rewrite, so it is reported but never promoted.
2536    #[test]
2537    fn test_fix_no_promote_structural_lines() {
2538        use crate::rule::Rule;
2539        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2540
2541        for content in [
2542            ">Quote\n\nBody.\n",
2543            "> Quote\n\nBody.\n",
2544            "- Item\n\nBody.\n",
2545            "* Item\n\nBody.\n",
2546            "+ Item\n\nBody.\n",
2547            "   - Indented item\n\nBody.\n",
2548            "***\n\nBody.\n",
2549            "---\n\nBody.\n",
2550            "```\n\nBody.\n",
2551            "    code\n\nBody.\n",
2552        ] {
2553            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2554            let warnings = rule.check(&ctx).unwrap();
2555            assert_eq!(warnings.len(), 1, "missing H1 is still reported for {content:?}");
2556            assert!(warnings[0].fix.is_none(), "no fix may be offered for {content:?}");
2557            assert_eq!(
2558                rule.fix(&ctx).unwrap(),
2559                content,
2560                "{content:?} must not become a heading"
2561            );
2562        }
2563
2564        // Positive control: the same shape of document with a paragraph first line
2565        let ctx = LintContext::new("Plain Title\n\nBody.\n", crate::config::MarkdownFlavor::Standard, None);
2566        assert_eq!(rule.fix(&ctx).unwrap(), "# Plain Title\n\nBody.\n");
2567    }
2568
2569    #[test]
2570    fn test_fix_promotes_multibyte_title_within_character_limit() {
2571        use crate::rule::Rule;
2572        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2573
2574        // 56 characters but 168 bytes: the limit is measured in characters
2575        let title = "日本語のタイトル".repeat(7);
2576        let content = format!("{title}\n\nBody.\n");
2577        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2578        let fixed = rule.fix(&ctx).unwrap();
2579        assert_eq!(fixed, format!("# {title}\n\nBody.\n"));
2580    }
2581
2582    #[test]
2583    fn test_fix_no_promote_if_no_blank_after() {
2584        use crate::rule::Rule;
2585        let rule = MD041FirstLineHeading {
2586            level: 1,
2587            front_matter_title: false,
2588            front_matter_title_pattern: None,
2589            allow_preamble: false,
2590            fix_enabled: true,
2591        };
2592
2593        // No blank line after potential title → NOT a title candidate
2594        let content = "My Project\nImmediately continues.\n\nContent.\n";
2595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2596        let fixed = rule.fix(&ctx).unwrap();
2597        assert_eq!(fixed, content, "Line without following blank should not be promoted");
2598    }
2599
2600    #[test]
2601    fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2602        use crate::rule::Rule;
2603        let rule = MD041FirstLineHeading {
2604            level: 1,
2605            front_matter_title: false,
2606            front_matter_title_pattern: None,
2607            allow_preamble: false,
2608            fix_enabled: true,
2609        };
2610
2611        // Title candidate exists but so does a heading later → can't safely fix
2612        // (the title candidate is content before the heading)
2613        let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2615        let fixed = rule.fix(&ctx).unwrap();
2616        assert_eq!(
2617            fixed, content,
2618            "Should not fix when title candidate exists before a heading"
2619        );
2620
2621        let warnings = rule.check(&ctx).unwrap();
2622        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2623    }
2624
2625    #[test]
2626    fn test_fix_promote_title_at_eof_no_trailing_newline() {
2627        use crate::rule::Rule;
2628        let rule = MD041FirstLineHeading {
2629            level: 1,
2630            front_matter_title: false,
2631            front_matter_title_pattern: None,
2632            allow_preamble: false,
2633            fix_enabled: true,
2634        };
2635
2636        // Single title line at EOF with no trailing newline
2637        let content = "My Project";
2638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639        let fixed = rule.fix(&ctx).unwrap();
2640        assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2641    }
2642
2643    // ── Phase 3 (Case B): InsertDerived ─────────────────────────────────────────
2644
2645    #[test]
2646    fn test_fix_insert_derived_directive_only_document() {
2647        use crate::rule::Rule;
2648        use std::path::PathBuf;
2649        let rule = MD041FirstLineHeading {
2650            level: 1,
2651            front_matter_title: false,
2652            front_matter_title_pattern: None,
2653            allow_preamble: false,
2654            fix_enabled: true,
2655        };
2656
2657        // Document with only a note admonition and no heading
2658        // (LintContext constructed with a source file path for title derivation)
2659        let content = "!!! note\n    This is a note.\n";
2660        let ctx = LintContext::new(
2661            content,
2662            crate::config::MarkdownFlavor::MkDocs,
2663            Some(PathBuf::from("setup-guide.md")),
2664        );
2665
2666        let can_fix = rule.can_fix(&ctx);
2667        assert!(can_fix, "Directive-only document with source file should be fixable");
2668
2669        let fixed = rule.fix(&ctx).unwrap();
2670        assert!(
2671            fixed.starts_with("# Setup Guide\n"),
2672            "Should insert derived heading, got: {fixed}"
2673        );
2674    }
2675
2676    #[test]
2677    fn test_fix_no_insert_derived_without_source_file() {
2678        use crate::rule::Rule;
2679        let rule = MD041FirstLineHeading {
2680            level: 1,
2681            front_matter_title: false,
2682            front_matter_title_pattern: None,
2683            allow_preamble: false,
2684            fix_enabled: true,
2685        };
2686
2687        // No source_file → derive_title returns None → InsertDerived unavailable
2688        let content = "!!! note\n    This is a note.\n";
2689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2690        let fixed = rule.fix(&ctx).unwrap();
2691        assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2692    }
2693
2694    #[test]
2695    fn test_fix_no_insert_derived_when_has_real_content() {
2696        use crate::rule::Rule;
2697        use std::path::PathBuf;
2698        let rule = MD041FirstLineHeading {
2699            level: 1,
2700            front_matter_title: false,
2701            front_matter_title_pattern: None,
2702            allow_preamble: false,
2703            fix_enabled: true,
2704        };
2705
2706        // Document has real paragraph content in addition to directive blocks
2707        let content = "!!! note\n    A note.\n\nSome paragraph text.\n";
2708        let ctx = LintContext::new(
2709            content,
2710            crate::config::MarkdownFlavor::MkDocs,
2711            Some(PathBuf::from("guide.md")),
2712        );
2713        let fixed = rule.fix(&ctx).unwrap();
2714        assert_eq!(
2715            fixed, content,
2716            "Should not insert derived heading when real content is present"
2717        );
2718    }
2719
2720    #[test]
2721    fn test_derive_title_converts_kebab_case() {
2722        use std::path::PathBuf;
2723        let ctx = LintContext::new(
2724            "",
2725            crate::config::MarkdownFlavor::Standard,
2726            Some(PathBuf::from("my-setup-guide.md")),
2727        );
2728        let title = MD041FirstLineHeading::derive_title(&ctx);
2729        assert_eq!(title, Some("My Setup Guide".to_string()));
2730    }
2731
2732    #[test]
2733    fn test_derive_title_converts_underscores() {
2734        use std::path::PathBuf;
2735        let ctx = LintContext::new(
2736            "",
2737            crate::config::MarkdownFlavor::Standard,
2738            Some(PathBuf::from("api_reference.md")),
2739        );
2740        let title = MD041FirstLineHeading::derive_title(&ctx);
2741        assert_eq!(title, Some("Api Reference".to_string()));
2742    }
2743
2744    #[test]
2745    fn test_derive_title_none_without_source_file() {
2746        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2747        let title = MD041FirstLineHeading::derive_title(&ctx);
2748        assert_eq!(title, None);
2749    }
2750
2751    #[test]
2752    fn test_derive_title_index_file_uses_parent_dir() {
2753        use std::path::PathBuf;
2754        let ctx = LintContext::new(
2755            "",
2756            crate::config::MarkdownFlavor::Standard,
2757            Some(PathBuf::from("docs/getting-started/index.md")),
2758        );
2759        let title = MD041FirstLineHeading::derive_title(&ctx);
2760        assert_eq!(title, Some("Getting Started".to_string()));
2761    }
2762
2763    #[test]
2764    fn test_derive_title_readme_file_uses_parent_dir() {
2765        use std::path::PathBuf;
2766        let ctx = LintContext::new(
2767            "",
2768            crate::config::MarkdownFlavor::Standard,
2769            Some(PathBuf::from("my-project/README.md")),
2770        );
2771        let title = MD041FirstLineHeading::derive_title(&ctx);
2772        assert_eq!(title, Some("My Project".to_string()));
2773    }
2774
2775    #[test]
2776    fn test_derive_title_index_without_parent_returns_none() {
2777        use std::path::PathBuf;
2778        // Root-level index.md has no meaningful parent — "Index" is not a useful title
2779        let ctx = LintContext::new(
2780            "",
2781            crate::config::MarkdownFlavor::Standard,
2782            Some(PathBuf::from("index.md")),
2783        );
2784        let title = MD041FirstLineHeading::derive_title(&ctx);
2785        assert_eq!(title, None);
2786    }
2787
2788    #[test]
2789    fn test_derive_title_readme_without_parent_returns_none() {
2790        use std::path::PathBuf;
2791        let ctx = LintContext::new(
2792            "",
2793            crate::config::MarkdownFlavor::Standard,
2794            Some(PathBuf::from("README.md")),
2795        );
2796        let title = MD041FirstLineHeading::derive_title(&ctx);
2797        assert_eq!(title, None);
2798    }
2799
2800    #[test]
2801    fn test_derive_title_readme_case_insensitive() {
2802        use std::path::PathBuf;
2803        // Lowercase readme.md should also use parent dir
2804        let ctx = LintContext::new(
2805            "",
2806            crate::config::MarkdownFlavor::Standard,
2807            Some(PathBuf::from("docs/api/readme.md")),
2808        );
2809        let title = MD041FirstLineHeading::derive_title(&ctx);
2810        assert_eq!(title, Some("Api".to_string()));
2811    }
2812
2813    #[test]
2814    fn test_is_title_candidate_basic() {
2815        assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2816        assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2817        assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2818    }
2819
2820    #[test]
2821    fn test_is_title_candidate_rejects_sentence_punctuation() {
2822        assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2823        assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2824        assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2825        assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2826        assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2827    }
2828
2829    #[test]
2830    fn test_is_title_candidate_rejects_when_no_blank_after() {
2831        assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2832    }
2833
2834    #[test]
2835    fn test_is_title_candidate_rejects_long_lines() {
2836        let long = "A".repeat(81);
2837        assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2838        // 80 chars is the boundary – exactly 80 is OK
2839        let ok = "A".repeat(80);
2840        assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2841    }
2842
2843    #[test]
2844    fn test_is_title_candidate_judges_text_shape_only() {
2845        // Structure is the parser's call (see the promotion tests); a line that
2846        // merely starts with a digit or a marker-like token is title text here
2847        assert!(MD041FirstLineHeading::is_title_candidate("2026 Roadmap", true));
2848        assert!(MD041FirstLineHeading::is_title_candidate("1.0 Release Notes", true));
2849        assert!(MD041FirstLineHeading::is_title_candidate("C++ Notes", true));
2850    }
2851
2852    #[test]
2853    fn test_is_title_candidate_length_counts_characters_not_bytes() {
2854        // 56 characters, 168 UTF-8 bytes: within the limit
2855        let cjk = "日本語のタイトル".repeat(7);
2856        assert_eq!(cjk.chars().count(), 56);
2857        assert!(MD041FirstLineHeading::is_title_candidate(&cjk, true));
2858        // 81 characters: over the limit
2859        let long = "日".repeat(81);
2860        assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2861    }
2862
2863    #[test]
2864    fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2865        // Verify that the fix replacement for plain-text-to-heading promotion is
2866        // non-empty, so applying the fix does not delete the line.
2867        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2868        // Title candidate: short text, no trailing punctuation, followed by blank line
2869        let content = "My Document Title\n\nMore content follows.";
2870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2871        let warnings = rule.check(&ctx).unwrap();
2872        assert_eq!(warnings.len(), 1);
2873        let fix = warnings[0]
2874            .fix
2875            .as_ref()
2876            .expect("Fix should be present for promotable text");
2877        assert!(
2878            !fix.replacement.is_empty(),
2879            "Fix replacement must not be empty — applying it directly must produce valid output"
2880        );
2881        assert!(
2882            fix.replacement.starts_with("# "),
2883            "Fix replacement should be a level-1 heading, got: {:?}",
2884            fix.replacement
2885        );
2886        assert_eq!(fix.replacement, "# My Document Title");
2887    }
2888
2889    #[test]
2890    fn test_fix_replacement_not_empty_for_releveling() {
2891        // When the first line is a heading at the wrong level, the Fix should
2892        // contain the correctly-leveled heading, not an empty string.
2893        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2894        let content = "## Wrong Level\n\nContent.";
2895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2896        let warnings = rule.check(&ctx).unwrap();
2897        assert_eq!(warnings.len(), 1);
2898        let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2899        assert!(
2900            !fix.replacement.is_empty(),
2901            "Fix replacement must not be empty for releveling"
2902        );
2903        assert_eq!(fix.replacement, "# Wrong Level");
2904    }
2905
2906    #[test]
2907    fn test_fix_replacement_applied_produces_valid_output() {
2908        // Verify that applying the Fix from check() produces the same result as fix()
2909        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2910        // Title candidate: short, no trailing punctuation, followed by blank line
2911        let content = "My Document\n\nMore content.";
2912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2913
2914        let warnings = rule.check(&ctx).unwrap();
2915        assert_eq!(warnings.len(), 1);
2916        let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2917
2918        // Apply Fix directly (like LSP would)
2919        let mut patched = content.to_string();
2920        patched.replace_range(fix.range.clone(), &fix.replacement);
2921
2922        // Apply via fix() method
2923        let fixed = rule.fix(&ctx).unwrap();
2924
2925        assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2926    }
2927
2928    #[test]
2929    fn test_mdx_disable_on_line_1_no_heading() {
2930        // The exact user scenario from issue #538:
2931        // MDX disable comment on line 1, NO heading anywhere.
2932        // The disable is the ONLY reason MD041 should not fire.
2933        let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2934        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2935
2936        // check() should produce a warning on line 2 (<Note> is the first content line)
2937        let rule = MD041FirstLineHeading::default();
2938        let warnings = rule.check(&ctx).unwrap();
2939        // The rule itself produces a warning, but the engine filters it via inline config.
2940        // MD041's check() doesn't filter inline config itself — the engine does.
2941        // What matters is that the warning is on line 2 (not line 1), so the engine
2942        // can see the disable is active at line 2 and suppress it.
2943        assert_eq!(
2944            warnings.len(),
2945            1,
2946            "The rule must emit the warning before engine filtering"
2947        );
2948        assert_eq!(warnings[0].line, 2, "Warning must point past the MDX disable comment");
2949        let rules: Vec<Box<dyn Rule>> = vec![Box::new(rule)];
2950        let filtered = crate::lint(content, &rules, false, crate::config::MarkdownFlavor::MDX, None, None).unwrap();
2951        assert!(
2952            filtered.is_empty(),
2953            "The engine must suppress the disabled rule: {filtered:?}"
2954        );
2955    }
2956
2957    #[test]
2958    fn test_mdx_disable_fix_returns_unchanged() {
2959        // fix() should return content unchanged when MDX disable is active
2960        let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2962        let rule = MD041FirstLineHeading {
2963            fix_enabled: true,
2964            ..MD041FirstLineHeading::default()
2965        };
2966        let result = rule.fix(&ctx).unwrap();
2967        assert_eq!(
2968            result, content,
2969            "fix() should not modify content when MD041 is disabled via MDX comment"
2970        );
2971    }
2972
2973    #[test]
2974    fn test_mdx_comment_without_disable_heading_on_next_line() {
2975        let rule = MD041FirstLineHeading::default();
2976
2977        // MDX comment (not a disable directive) on line 1, heading on line 2
2978        let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2979        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2980        let result = rule.check(&ctx).unwrap();
2981        assert!(
2982            result.is_empty(),
2983            "MDX comment is preamble; heading on next line should satisfy MD041"
2984        );
2985    }
2986
2987    #[test]
2988    fn test_mdx_comment_without_heading_triggers_warning() {
2989        let rule = MD041FirstLineHeading::default();
2990
2991        // MDX comment on line 1, non-heading content on line 2
2992        let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2993        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2994        let result = rule.check(&ctx).unwrap();
2995        assert_eq!(
2996            result.len(),
2997            1,
2998            "MDX comment followed by non-heading should trigger MD041"
2999        );
3000        assert_eq!(
3001            result[0].line, 2,
3002            "Warning should be on line 2 (the first content line after MDX comment)"
3003        );
3004    }
3005
3006    #[test]
3007    fn test_multiline_mdx_comment_followed_by_heading() {
3008        let rule = MD041FirstLineHeading::default();
3009
3010        // Multi-line MDX comment followed by heading
3011        let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
3012        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
3013        let result = rule.check(&ctx).unwrap();
3014        assert!(
3015            result.is_empty(),
3016            "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
3017        );
3018    }
3019
3020    #[test]
3021    fn test_html_comment_still_works_as_preamble_regression() {
3022        let rule = MD041FirstLineHeading::default();
3023
3024        // Plain HTML comment on line 1, heading on line 2
3025        let content = "<!-- Some comment -->\n# My Document\n\nContent.";
3026        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3027        let result = rule.check(&ctx).unwrap();
3028        assert!(
3029            result.is_empty(),
3030            "HTML comment should still be treated as preamble (regression test)"
3031        );
3032    }
3033
3034    #[test]
3035    fn test_mdg_requires_first_h1() {
3036        // `# Feature: X` is always writable, so the MD041 policy stays in force
3037        // for MDG and must match Standard.
3038        let rule = MD041FirstLineHeading::default();
3039        let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
3040
3041        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3042        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3043
3044        assert!(!rule.check(&mdg_ctx).unwrap().is_empty());
3045        assert_eq!(
3046            rule.check(&mdg_ctx).unwrap().len(),
3047            rule.check(&standard_ctx).unwrap().len(),
3048            "MDG must not differ from Standard"
3049        );
3050    }
3051
3052    #[test]
3053    fn test_mdg_fix_relevels_without_relocating_content() {
3054        // With fixes enabled the only change must be an in-place relevel; the
3055        // Feature tag line and the document order have to survive untouched.
3056        let rule = MD041FirstLineHeading {
3057            fix_enabled: true,
3058            ..MD041FirstLineHeading::default()
3059        };
3060        let content = "### Feature: Checkout\n\n##### Scenario: Purchase\n";
3061        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
3062
3063        let fixed = rule.fix(&ctx).unwrap();
3064        assert_eq!(fixed, "# Feature: Checkout\n\n##### Scenario: Purchase\n");
3065
3066        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
3067        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
3068    }
3069
3070    #[test]
3071    fn test_setext_heading_opened_by_badge_lines_is_judged_at_its_last_line() {
3072        // The badge lines the rule passes over open the paragraph the underline
3073        // ends, so the judged line is the heading's last text line, not its
3074        // first, and the warning covers that line alone.
3075        let rule = MD041FirstLineHeading {
3076            fix_enabled: true,
3077            ..MD041FirstLineHeading::default()
3078        };
3079        let content = "![a](a.png)\n![b](b.png)\nTitle\n---\n";
3080        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3081
3082        let result = rule.check(&ctx).unwrap();
3083        assert_eq!(result.len(), 1);
3084        assert_eq!(
3085            (
3086                result[0].line,
3087                result[0].column,
3088                result[0].end_line,
3089                result[0].end_column
3090            ),
3091            (3, 1, 3, 6)
3092        );
3093        assert!(result[0].message.contains("level 1 heading"));
3094
3095        // The whole paragraph is the heading's text, badge lines included, so
3096        // releveling rewrites all of it as one ATX line.
3097        assert_eq!(rule.fix(&ctx).unwrap(), "# ![a](a.png) ![b](b.png) Title\n");
3098    }
3099    #[test]
3100    fn test_literal_headings_do_not_select_first_top_level_heading() {
3101        let rule = MD041FirstLineHeading {
3102            allow_preamble: true,
3103            fix_enabled: true,
3104            ..Default::default()
3105        };
3106        for literal in [
3107            "# Fake",
3108            "## Fake",
3109            "\nFake\n===\n",
3110            "\nFake\n---\n",
3111            "<h1>Fake</h1>",
3112            "<h2>Fake</h2>",
3113        ] {
3114            for (flavor, code) in [
3115                (
3116                    crate::config::MarkdownFlavor::Standard,
3117                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
3118                ),
3119                (
3120                    crate::config::MarkdownFlavor::Hugo,
3121                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
3122                ),
3123            ] {
3124                for actual in ["# Visible", "## Visible"] {
3125                    for ending in ["\n", "\r\n"] {
3126                        let source = format!("{code}\n\n{actual}\n").replace('\n', ending);
3127                        let expected = format!("{code}\n\n# Visible\n").replace('\n', ending);
3128                        let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
3129                        let warnings = rule.check(&ctx).unwrap();
3130                        assert_eq!(warnings.len(), usize::from(actual != "# Visible"), "{source}");
3131                        if let Some(warning) = warnings.first() {
3132                            assert_eq!(warning.line, source.lines().count());
3133                            assert!(warning.fix.is_some());
3134                        }
3135                        assert_eq!(
3136                            crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
3137                            expected,
3138                            "{source}"
3139                        );
3140                        // Preserve the direct fixer's existing LF normalization on rewrites.
3141                        let direct_expected = if actual == "# Visible" {
3142                            source.clone()
3143                        } else {
3144                            expected.replace("\r\n", "\n")
3145                        };
3146                        assert_eq!(rule.fix(&ctx).unwrap(), direct_expected, "{source}");
3147                    }
3148                }
3149            }
3150        }
3151    }
3152
3153    #[test]
3154    fn test_literal_only_document_has_no_first_top_level_heading() {
3155        let rule = MD041FirstLineHeading {
3156            allow_preamble: true,
3157            fix_enabled: true,
3158            ..Default::default()
3159        };
3160        for literal in ["## Fake", "\nFake\n---\n", "<h2>Fake</h2>"] {
3161            let source = format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}\n");
3162            let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
3163            assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
3164            assert_eq!(rule.fix(&ctx).unwrap(), source);
3165        }
3166        let source = "{% set unused=\"Fake\n---\nlast\" %}{{ unused|length }}\n\n# Visible\n";
3167        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
3168        assert!(rule.check(&ctx).unwrap().is_empty());
3169        assert_eq!(rule.fix(&ctx).unwrap(), source);
3170    }
3171
3172    #[test]
3173    fn test_literal_first_heading_guard_preserves_other_modes() {
3174        let rule = MD041FirstLineHeading {
3175            fix_enabled: true,
3176            ..Default::default()
3177        };
3178        let source = "{% set unused=\"first\n# Fake\nlast\" %}{{ unused|length }}\n\n# Visible\n";
3179        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
3180        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
3181        assert_eq!(rule.fix(&ctx).unwrap(), source);
3182        let preamble = rule.with_allow_preamble(true);
3183        for (flavor, source) in [
3184            (crate::config::MarkdownFlavor::Standard, "Intro.\n\n## {{ title }}\n"),
3185            (crate::config::MarkdownFlavor::MDX, "<div>\n\n## Visible\n\n</div>\n"),
3186        ] {
3187            let ctx = crate::lint_context::LintContext::new(source, flavor, None);
3188            assert_eq!(preamble.check(&ctx).unwrap().len(), 1);
3189            assert_eq!(preamble.fix(&ctx).unwrap(), source.replacen("## ", "# ", 1));
3190        }
3191    }
3192}