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