Skip to main content

rumdl_lib/rules/
md041_first_line_heading.rs

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