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