Skip to main content

rumdl_lib/rules/md041_first_line_heading/
mod.rs

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