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, 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/// Analysis result for fix eligibility (internal helper)
37struct FixAnalysis {
38    front_matter_end_idx: usize,
39    heading_idx: usize,
40    is_setext: bool,
41    current_level: usize,
42    needs_level_fix: bool,
43}
44
45impl MD041FirstLineHeading {
46    pub fn new(level: usize, front_matter_title: bool) -> Self {
47        Self {
48            level,
49            front_matter_title,
50            front_matter_title_pattern: None,
51            fix_enabled: false,
52        }
53    }
54
55    pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
56        let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
57            Ok(regex) => Some(regex),
58            Err(e) => {
59                log::warn!("Invalid front_matter_title_pattern regex: {e}");
60                None
61            }
62        });
63
64        Self {
65            level,
66            front_matter_title,
67            front_matter_title_pattern,
68            fix_enabled,
69        }
70    }
71
72    fn has_front_matter_title(&self, content: &str) -> bool {
73        if !self.front_matter_title {
74            return false;
75        }
76
77        // If we have a custom pattern, use it to search front matter content
78        if let Some(ref pattern) = self.front_matter_title_pattern {
79            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
80            for line in front_matter_lines {
81                if pattern.is_match(line) {
82                    return true;
83                }
84            }
85            return false;
86        }
87
88        // Default behavior: check for "title:" field
89        FrontMatterUtils::has_front_matter_field(content, "title:")
90    }
91
92    /// Check if a line is a non-content token that should be skipped
93    fn is_non_content_line(line: &str) -> bool {
94        let trimmed = line.trim();
95
96        // Skip reference definitions
97        if trimmed.starts_with('[') && trimmed.contains("]: ") {
98            return true;
99        }
100
101        // Skip abbreviation definitions
102        if trimmed.starts_with('*') && trimmed.contains("]: ") {
103            return true;
104        }
105
106        // Skip badge/shield images - common pattern at top of READMEs
107        // Matches: ![badge](url) or [![badge](url)](url)
108        if Self::is_badge_image_line(trimmed) {
109            return true;
110        }
111
112        false
113    }
114
115    /// Check if a line consists only of badge/shield images
116    /// Common patterns:
117    /// - `![badge](url)`
118    /// - `[![badge](url)](url)` (linked badge)
119    /// - Multiple badges on one line
120    fn is_badge_image_line(line: &str) -> bool {
121        if line.is_empty() {
122            return false;
123        }
124
125        // Must start with image syntax
126        if !line.starts_with('!') && !line.starts_with('[') {
127            return false;
128        }
129
130        // Check if line contains only image/link patterns and whitespace
131        let mut remaining = line;
132        while !remaining.is_empty() {
133            remaining = remaining.trim_start();
134            if remaining.is_empty() {
135                break;
136            }
137
138            // Linked image: [![alt](img-url)](link-url)
139            if remaining.starts_with("[![") {
140                if let Some(end) = Self::find_linked_image_end(remaining) {
141                    remaining = &remaining[end..];
142                    continue;
143                }
144                return false;
145            }
146
147            // Simple image: ![alt](url)
148            if remaining.starts_with("![") {
149                if let Some(end) = Self::find_image_end(remaining) {
150                    remaining = &remaining[end..];
151                    continue;
152                }
153                return false;
154            }
155
156            // Not an image pattern
157            return false;
158        }
159
160        true
161    }
162
163    /// Find the end of an image pattern ![alt](url)
164    fn find_image_end(s: &str) -> Option<usize> {
165        if !s.starts_with("![") {
166            return None;
167        }
168        // Find ]( after ![
169        let alt_end = s[2..].find("](")?;
170        let paren_start = 2 + alt_end + 2; // Position after ](
171        // Find closing )
172        let paren_end = s[paren_start..].find(')')?;
173        Some(paren_start + paren_end + 1)
174    }
175
176    /// Find the end of a linked image pattern [![alt](img-url)](link-url)
177    fn find_linked_image_end(s: &str) -> Option<usize> {
178        if !s.starts_with("[![") {
179            return None;
180        }
181        // Find the inner image first
182        let inner_end = Self::find_image_end(&s[1..])?;
183        let after_inner = 1 + inner_end;
184        // Should be followed by ](url)
185        if !s[after_inner..].starts_with("](") {
186            return None;
187        }
188        let link_start = after_inner + 2;
189        let link_end = s[link_start..].find(')')?;
190        Some(link_start + link_end + 1)
191    }
192
193    /// Fix a heading line to use the specified level
194    fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
195        let trimmed = line.trim_start();
196
197        // ATX-style heading (# Heading)
198        if trimmed.starts_with('#') {
199            let hashes = "#".repeat(target_level);
200            // Find where the content starts (after # and optional space)
201            let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
202            let after_hashes = &trimmed[content_start..];
203            let content = after_hashes.trim_start();
204
205            // Preserve leading whitespace from original line
206            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
207            format!("{leading_ws}{hashes} {content}")
208        } else {
209            // Setext-style heading - convert to ATX
210            // The underline would be on the next line, so we just convert the text line
211            let hashes = "#".repeat(target_level);
212            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
213            format!("{leading_ws}{hashes} {trimmed}")
214        }
215    }
216
217    /// Check if a line is an HTML heading using the centralized HTML parser
218    fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
219        // Check for single-line HTML heading using regex (fast path)
220        let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
221        if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
222            && let Some(h_level) = captures.get(1)
223            && h_level.as_str().parse::<usize>().unwrap_or(0) == level
224        {
225            return true;
226        }
227
228        // Use centralized HTML parser for multi-line headings
229        let html_tags = ctx.html_tags();
230        let target_tag = format!("h{level}");
231
232        // Find opening tag on first line
233        let opening_index = html_tags.iter().position(|tag| {
234            tag.line == first_line_idx + 1 // HtmlTag uses 1-indexed lines
235                && tag.tag_name == target_tag
236                && !tag.is_closing
237        });
238
239        let Some(open_idx) = opening_index else {
240            return false;
241        };
242
243        // Walk HTML tags to find the corresponding closing tag, allowing arbitrary nesting depth.
244        // This avoids brittle line-count heuristics and handles long headings with nested content.
245        let mut depth = 1usize;
246        for tag in html_tags.iter().skip(open_idx + 1) {
247            // Ignore tags that appear before the first heading line (possible when multiple tags share a line)
248            if tag.line <= first_line_idx + 1 {
249                continue;
250            }
251
252            if tag.tag_name == target_tag {
253                if tag.is_closing {
254                    depth -= 1;
255                    if depth == 0 {
256                        return true;
257                    }
258                } else if !tag.is_self_closing {
259                    depth += 1;
260                }
261            }
262        }
263
264        false
265    }
266
267    /// Analyze document to determine if it can be fixed and gather metadata.
268    /// Returns None if not fixable, Some(analysis) if fixable.
269    fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixAnalysis> {
270        let lines = ctx.raw_lines();
271        if lines.is_empty() {
272            return None;
273        }
274
275        // Find front matter end
276        let mut front_matter_end_idx = 0;
277        if lines.first().map(|l| l.trim()) == Some("---") {
278            for (idx, line) in lines.iter().enumerate().skip(1) {
279                if line.trim() == "---" {
280                    front_matter_end_idx = idx + 1;
281                    break;
282                }
283            }
284        }
285
286        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
287        let mut has_non_preamble_before_heading = false;
288
289        for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
290            let line_content = line_info.content(ctx.content);
291            let trimmed = line_content.trim();
292
293            // Check if this is preamble (skip these)
294            let is_preamble = trimmed.is_empty()
295                || line_info.in_html_comment
296                || Self::is_non_content_line(line_content)
297                || (is_mkdocs && is_mkdocs_anchor_line(line_content));
298
299            if is_preamble {
300                continue;
301            }
302
303            // Check for ATX or Setext heading (not HTML heading - we can't fix those)
304            if let Some(heading) = &line_info.heading {
305                // Can't fix if there's non-preamble content before the heading
306                if has_non_preamble_before_heading {
307                    return None;
308                }
309
310                let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
311                let current_level = heading.level as usize;
312                let needs_level_fix = current_level != self.level;
313                let needs_move = idx > front_matter_end_idx;
314
315                // Only return analysis if there's something to fix
316                if needs_level_fix || needs_move {
317                    return Some(FixAnalysis {
318                        front_matter_end_idx,
319                        heading_idx: idx,
320                        is_setext,
321                        current_level,
322                        needs_level_fix,
323                    });
324                } else {
325                    return None; // Already correct
326                }
327            } else {
328                // Non-heading, non-preamble content found before any heading
329                has_non_preamble_before_heading = true;
330            }
331        }
332
333        // No ATX/Setext heading found
334        None
335    }
336
337    /// Determine if this document can be auto-fixed.
338    fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
339        self.fix_enabled && self.analyze_for_fix(ctx).is_some()
340    }
341}
342
343impl Rule for MD041FirstLineHeading {
344    fn name(&self) -> &'static str {
345        "MD041"
346    }
347
348    fn description(&self) -> &'static str {
349        "First line in file should be a top level heading"
350    }
351
352    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
353        let mut warnings = Vec::new();
354
355        // Check if we should skip this file
356        if self.should_skip(ctx) {
357            return Ok(warnings);
358        }
359
360        // Find the first non-blank line after front matter using cached info
361        let mut first_content_line_num = None;
362        let mut skip_lines = 0;
363
364        // Check for front matter
365        if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
366            // Skip front matter
367            for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
368                if line_info.content(ctx.content).trim() == "---" {
369                    skip_lines = idx + 1;
370                    break;
371                }
372            }
373        }
374
375        // Check if we're in MkDocs flavor
376        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
377
378        for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
379            let line_content = line_info.content(ctx.content);
380            let trimmed = line_content.trim();
381            // Skip ESM blocks in MDX files (import/export statements)
382            if line_info.in_esm_block {
383                continue;
384            }
385            // Skip HTML comments - they are non-visible and should not affect MD041
386            if line_info.in_html_comment {
387                continue;
388            }
389            // Skip MkDocs anchor lines (empty link with attr_list) when in MkDocs flavor
390            if is_mkdocs && is_mkdocs_anchor_line(line_content) {
391                continue;
392            }
393            if !trimmed.is_empty() && !Self::is_non_content_line(line_content) {
394                first_content_line_num = Some(line_num);
395                break;
396            }
397        }
398
399        if first_content_line_num.is_none() {
400            // No non-blank lines after front matter
401            return Ok(warnings);
402        }
403
404        let first_line_idx = first_content_line_num.unwrap();
405
406        // Check if the first non-blank line is a heading of the required level
407        let first_line_info = &ctx.lines[first_line_idx];
408        let is_correct_heading = if let Some(heading) = &first_line_info.heading {
409            heading.level as usize == self.level
410        } else {
411            // Check for HTML heading (both single-line and multi-line)
412            Self::is_html_heading(ctx, first_line_idx, self.level)
413        };
414
415        if !is_correct_heading {
416            // Calculate precise character range for the entire first line
417            let first_line = first_line_idx + 1; // Convert to 1-indexed
418            let first_line_content = first_line_info.content(ctx.content);
419            let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
420
421            // Only provide fix suggestion if the fix is actually applicable
422            // can_fix checks: fix_enabled, heading exists, no content before heading, not HTML heading
423            let fix = if self.can_fix(ctx) {
424                let range_start = first_line_info.byte_offset;
425                let range_end = range_start + first_line_info.byte_len;
426                Some(Fix {
427                    range: range_start..range_end,
428                    replacement: String::new(), // Placeholder - fix() method handles actual replacement
429                })
430            } else {
431                None
432            };
433
434            warnings.push(LintWarning {
435                rule_name: Some(self.name().to_string()),
436                line: start_line,
437                column: start_col,
438                end_line,
439                end_column: end_col,
440                message: format!("First line in file should be a level {} heading", self.level),
441                severity: Severity::Warning,
442                fix,
443            });
444        }
445        Ok(warnings)
446    }
447
448    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
449        // Only fix if explicitly enabled via config
450        if !self.fix_enabled {
451            return Ok(ctx.content.to_string());
452        }
453
454        // Skip if should_skip returns true (front matter title, empty content, etc.)
455        if self.should_skip(ctx) {
456            return Ok(ctx.content.to_string());
457        }
458
459        // Use shared analysis to determine what needs fixing
460        let Some(analysis) = self.analyze_for_fix(ctx) else {
461            return Ok(ctx.content.to_string());
462        };
463
464        let lines = ctx.raw_lines();
465        let heading_idx = analysis.heading_idx;
466        let front_matter_end_idx = analysis.front_matter_end_idx;
467        let is_setext = analysis.is_setext;
468
469        let heading_info = &ctx.lines[heading_idx];
470        let heading_line = heading_info.content(ctx.content);
471
472        // Prepare the heading (fix level if needed, always convert Setext to ATX)
473        let fixed_heading = if analysis.needs_level_fix || is_setext {
474            self.fix_heading_level(heading_line, analysis.current_level, self.level)
475        } else {
476            heading_line.to_string()
477        };
478
479        // Build the result
480        let mut result = String::new();
481
482        // Add front matter if present
483        for line in lines.iter().take(front_matter_end_idx) {
484            result.push_str(line);
485            result.push('\n');
486        }
487
488        // Add the heading right after front matter
489        result.push_str(&fixed_heading);
490        result.push('\n');
491
492        // Add remaining content, skipping the original heading line and Setext underline
493        for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
494            // Skip the original heading line
495            if idx == heading_idx {
496                continue;
497            }
498            // Skip the Setext underline (line after heading)
499            if is_setext && idx == heading_idx + 1 {
500                continue;
501            }
502            result.push_str(line);
503            result.push('\n');
504        }
505
506        // Remove trailing newline if original didn't have one
507        if !ctx.content.ends_with('\n') && result.ends_with('\n') {
508            result.pop();
509        }
510
511        Ok(result)
512    }
513
514    /// Check if this rule should be skipped
515    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
516        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
517        // These files are composition/routing metadata, not standalone content.
518        // Example: A file containing only "{{#include ../../README.md}}" is a
519        // pointer to content, not content itself, and shouldn't need a heading.
520        let only_directives = !ctx.content.is_empty()
521            && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
522                let t = l.trim();
523                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
524                (t.starts_with("{{#") && t.ends_with("}}"))
525                        // HTML comments often accompany directives
526                        || (t.starts_with("<!--") && t.ends_with("-->"))
527            });
528
529        ctx.content.is_empty()
530            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
531            || only_directives
532    }
533
534    fn as_any(&self) -> &dyn std::any::Any {
535        self
536    }
537
538    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
539    where
540        Self: Sized,
541    {
542        // Load config using serde with kebab-case support
543        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
544
545        let use_front_matter = !md041_config.front_matter_title.is_empty();
546
547        Box::new(MD041FirstLineHeading::with_pattern(
548            md041_config.level.as_usize(),
549            use_front_matter,
550            md041_config.front_matter_title_pattern,
551            md041_config.fix,
552        ))
553    }
554
555    fn default_config_section(&self) -> Option<(String, toml::Value)> {
556        Some((
557            "MD041".to_string(),
558            toml::toml! {
559                level = 1
560                front-matter-title = "title"
561                front-matter-title-pattern = ""
562                fix = false
563            }
564            .into(),
565        ))
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::lint_context::LintContext;
573
574    #[test]
575    fn test_first_line_is_heading_correct_level() {
576        let rule = MD041FirstLineHeading::default();
577
578        // First line is a level 1 heading (should pass)
579        let content = "# My Document\n\nSome content here.";
580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581        let result = rule.check(&ctx).unwrap();
582        assert!(
583            result.is_empty(),
584            "Expected no warnings when first line is a level 1 heading"
585        );
586    }
587
588    #[test]
589    fn test_first_line_is_heading_wrong_level() {
590        let rule = MD041FirstLineHeading::default();
591
592        // First line is a level 2 heading (should fail with level 1 requirement)
593        let content = "## My Document\n\nSome content here.";
594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595        let result = rule.check(&ctx).unwrap();
596        assert_eq!(result.len(), 1);
597        assert_eq!(result[0].line, 1);
598        assert!(result[0].message.contains("level 1 heading"));
599    }
600
601    #[test]
602    fn test_first_line_not_heading() {
603        let rule = MD041FirstLineHeading::default();
604
605        // First line is plain text (should fail)
606        let content = "This is not a heading\n\n# This is a heading";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609        assert_eq!(result.len(), 1);
610        assert_eq!(result[0].line, 1);
611        assert!(result[0].message.contains("level 1 heading"));
612    }
613
614    #[test]
615    fn test_empty_lines_before_heading() {
616        let rule = MD041FirstLineHeading::default();
617
618        // Empty lines before first heading (should pass - rule skips empty lines)
619        let content = "\n\n# My Document\n\nSome content.";
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621        let result = rule.check(&ctx).unwrap();
622        assert!(
623            result.is_empty(),
624            "Expected no warnings when empty lines precede a valid heading"
625        );
626
627        // Empty lines before non-heading content (should fail)
628        let content = "\n\nNot a heading\n\nSome content.";
629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630        let result = rule.check(&ctx).unwrap();
631        assert_eq!(result.len(), 1);
632        assert_eq!(result[0].line, 3); // First non-empty line
633        assert!(result[0].message.contains("level 1 heading"));
634    }
635
636    #[test]
637    fn test_front_matter_with_title() {
638        let rule = MD041FirstLineHeading::new(1, true);
639
640        // Front matter with title field (should pass)
641        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let result = rule.check(&ctx).unwrap();
644        assert!(
645            result.is_empty(),
646            "Expected no warnings when front matter has title field"
647        );
648    }
649
650    #[test]
651    fn test_front_matter_without_title() {
652        let rule = MD041FirstLineHeading::new(1, true);
653
654        // Front matter without title field (should fail)
655        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
656        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657        let result = rule.check(&ctx).unwrap();
658        assert_eq!(result.len(), 1);
659        assert_eq!(result[0].line, 6); // First content line after front matter
660    }
661
662    #[test]
663    fn test_front_matter_disabled() {
664        let rule = MD041FirstLineHeading::new(1, false);
665
666        // Front matter with title field but front_matter_title is false (should fail)
667        let content = "---\ntitle: My Document\n---\n\nSome content here.";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let result = rule.check(&ctx).unwrap();
670        assert_eq!(result.len(), 1);
671        assert_eq!(result[0].line, 5); // First content line after front matter
672    }
673
674    #[test]
675    fn test_html_comments_before_heading() {
676        let rule = MD041FirstLineHeading::default();
677
678        // HTML comment before heading (should pass - comments are skipped, issue #155)
679        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
680        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681        let result = rule.check(&ctx).unwrap();
682        assert!(
683            result.is_empty(),
684            "HTML comments should be skipped when checking for first heading"
685        );
686    }
687
688    #[test]
689    fn test_multiline_html_comment_before_heading() {
690        let rule = MD041FirstLineHeading::default();
691
692        // Multi-line HTML comment before heading (should pass - issue #155)
693        let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
694        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695        let result = rule.check(&ctx).unwrap();
696        assert!(
697            result.is_empty(),
698            "Multi-line HTML comments should be skipped when checking for first heading"
699        );
700    }
701
702    #[test]
703    fn test_html_comment_with_blank_lines_before_heading() {
704        let rule = MD041FirstLineHeading::default();
705
706        // HTML comment with blank lines before heading (should pass - issue #155)
707        let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709        let result = rule.check(&ctx).unwrap();
710        assert!(
711            result.is_empty(),
712            "HTML comments with blank lines should be skipped when checking for first heading"
713        );
714    }
715
716    #[test]
717    fn test_html_comment_before_html_heading() {
718        let rule = MD041FirstLineHeading::default();
719
720        // HTML comment before HTML heading (should pass - issue #155)
721        let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
722        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723        let result = rule.check(&ctx).unwrap();
724        assert!(
725            result.is_empty(),
726            "HTML comments should be skipped before HTML headings"
727        );
728    }
729
730    #[test]
731    fn test_document_with_only_html_comments() {
732        let rule = MD041FirstLineHeading::default();
733
734        // Document with only HTML comments (should pass - no warnings for comment-only files)
735        let content = "<!-- This is a comment -->\n<!-- Another comment -->";
736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737        let result = rule.check(&ctx).unwrap();
738        assert!(
739            result.is_empty(),
740            "Documents with only HTML comments should not trigger MD041"
741        );
742    }
743
744    #[test]
745    fn test_html_comment_followed_by_non_heading() {
746        let rule = MD041FirstLineHeading::default();
747
748        // HTML comment followed by non-heading content (should still fail - issue #155)
749        let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
750        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751        let result = rule.check(&ctx).unwrap();
752        assert_eq!(
753            result.len(),
754            1,
755            "HTML comment followed by non-heading should still trigger MD041"
756        );
757        assert_eq!(
758            result[0].line, 2,
759            "Warning should be on the first non-comment, non-heading line"
760        );
761    }
762
763    #[test]
764    fn test_multiple_html_comments_before_heading() {
765        let rule = MD041FirstLineHeading::default();
766
767        // Multiple HTML comments before heading (should pass - issue #155)
768        let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770        let result = rule.check(&ctx).unwrap();
771        assert!(
772            result.is_empty(),
773            "Multiple HTML comments should all be skipped before heading"
774        );
775    }
776
777    #[test]
778    fn test_html_comment_with_wrong_level_heading() {
779        let rule = MD041FirstLineHeading::default();
780
781        // HTML comment followed by wrong-level heading (should fail - issue #155)
782        let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
783        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784        let result = rule.check(&ctx).unwrap();
785        assert_eq!(
786            result.len(),
787            1,
788            "HTML comment followed by wrong-level heading should still trigger MD041"
789        );
790        assert!(
791            result[0].message.contains("level 1 heading"),
792            "Should require level 1 heading"
793        );
794    }
795
796    #[test]
797    fn test_html_comment_mixed_with_reference_definitions() {
798        let rule = MD041FirstLineHeading::default();
799
800        // HTML comment mixed with reference definitions before heading (should pass - issue #155)
801        let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803        let result = rule.check(&ctx).unwrap();
804        assert!(
805            result.is_empty(),
806            "HTML comments and reference definitions should both be skipped before heading"
807        );
808    }
809
810    #[test]
811    fn test_html_comment_after_front_matter() {
812        let rule = MD041FirstLineHeading::default();
813
814        // HTML comment after front matter, before heading (should pass - issue #155)
815        let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
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            "HTML comments after front matter should be skipped before heading"
821        );
822    }
823
824    #[test]
825    fn test_html_comment_not_at_start_should_not_affect_rule() {
826        let rule = MD041FirstLineHeading::default();
827
828        // HTML comment in middle of document should not affect MD041 check
829        let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
830        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831        let result = rule.check(&ctx).unwrap();
832        assert!(
833            result.is_empty(),
834            "HTML comments in middle of document should not affect MD041 (only first content matters)"
835        );
836    }
837
838    #[test]
839    fn test_multiline_html_comment_followed_by_non_heading() {
840        let rule = MD041FirstLineHeading::default();
841
842        // Multi-line HTML comment followed by non-heading (should still fail - issue #155)
843        let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845        let result = rule.check(&ctx).unwrap();
846        assert_eq!(
847            result.len(),
848            1,
849            "Multi-line HTML comment followed by non-heading should still trigger MD041"
850        );
851        assert_eq!(
852            result[0].line, 5,
853            "Warning should be on the first non-comment, non-heading line"
854        );
855    }
856
857    #[test]
858    fn test_different_heading_levels() {
859        // Test with level 2 requirement
860        let rule = MD041FirstLineHeading::new(2, false);
861
862        let content = "## Second Level Heading\n\nContent.";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
866
867        // Wrong level
868        let content = "# First Level Heading\n\nContent.";
869        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870        let result = rule.check(&ctx).unwrap();
871        assert_eq!(result.len(), 1);
872        assert!(result[0].message.contains("level 2 heading"));
873    }
874
875    #[test]
876    fn test_setext_headings() {
877        let rule = MD041FirstLineHeading::default();
878
879        // Setext style level 1 heading (should pass)
880        let content = "My Document\n===========\n\nContent.";
881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882        let result = rule.check(&ctx).unwrap();
883        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
884
885        // Setext style level 2 heading (should fail with level 1 requirement)
886        let content = "My Document\n-----------\n\nContent.";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let result = rule.check(&ctx).unwrap();
889        assert_eq!(result.len(), 1);
890        assert!(result[0].message.contains("level 1 heading"));
891    }
892
893    #[test]
894    fn test_empty_document() {
895        let rule = MD041FirstLineHeading::default();
896
897        // Empty document (should pass - no warnings)
898        let content = "";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900        let result = rule.check(&ctx).unwrap();
901        assert!(result.is_empty(), "Expected no warnings for empty document");
902    }
903
904    #[test]
905    fn test_whitespace_only_document() {
906        let rule = MD041FirstLineHeading::default();
907
908        // Document with only whitespace (should pass - no warnings)
909        let content = "   \n\n   \t\n";
910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911        let result = rule.check(&ctx).unwrap();
912        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
913    }
914
915    #[test]
916    fn test_front_matter_then_whitespace() {
917        let rule = MD041FirstLineHeading::default();
918
919        // Front matter followed by only whitespace (should pass - no warnings)
920        let content = "---\ntitle: Test\n---\n\n   \n\n";
921        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922        let result = rule.check(&ctx).unwrap();
923        assert!(
924            result.is_empty(),
925            "Expected no warnings when no content after front matter"
926        );
927    }
928
929    #[test]
930    fn test_multiple_front_matter_types() {
931        let rule = MD041FirstLineHeading::new(1, true);
932
933        // TOML front matter with title (should fail - rule only checks for "title:" pattern)
934        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936        let result = rule.check(&ctx).unwrap();
937        assert_eq!(result.len(), 1);
938        assert!(result[0].message.contains("level 1 heading"));
939
940        // JSON front matter with title (should fail - doesn't have "title:" pattern, has "\"title\":")
941        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
942        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943        let result = rule.check(&ctx).unwrap();
944        assert_eq!(result.len(), 1);
945        assert!(result[0].message.contains("level 1 heading"));
946
947        // YAML front matter with title field (standard case)
948        let content = "---\ntitle: My Document\n---\n\nContent.";
949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950        let result = rule.check(&ctx).unwrap();
951        assert!(
952            result.is_empty(),
953            "Expected no warnings for YAML front matter with title"
954        );
955
956        // Test mixed format edge case - YAML-style in TOML
957        let content = "+++\ntitle: My Document\n+++\n\nContent.";
958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959        let result = rule.check(&ctx).unwrap();
960        assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
961    }
962
963    #[test]
964    fn test_malformed_front_matter() {
965        let rule = MD041FirstLineHeading::new(1, true);
966
967        // Malformed front matter with title
968        let content = "- --\ntitle: My Document\n- --\n\nContent.";
969        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970        let result = rule.check(&ctx).unwrap();
971        assert!(
972            result.is_empty(),
973            "Expected no warnings for malformed front matter with title"
974        );
975    }
976
977    #[test]
978    fn test_front_matter_with_heading() {
979        let rule = MD041FirstLineHeading::default();
980
981        // Front matter without title field followed by correct heading
982        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let result = rule.check(&ctx).unwrap();
985        assert!(
986            result.is_empty(),
987            "Expected no warnings when first line after front matter is correct heading"
988        );
989    }
990
991    #[test]
992    fn test_no_fix_suggestion() {
993        let rule = MD041FirstLineHeading::default();
994
995        // Check that NO fix suggestion is provided (MD041 is now detection-only)
996        let content = "Not a heading\n\nContent.";
997        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998        let result = rule.check(&ctx).unwrap();
999        assert_eq!(result.len(), 1);
1000        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1001    }
1002
1003    #[test]
1004    fn test_complex_document_structure() {
1005        let rule = MD041FirstLineHeading::default();
1006
1007        // Complex document with various elements - HTML comment should be skipped (issue #155)
1008        let content =
1009            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011        let result = rule.check(&ctx).unwrap();
1012        assert!(
1013            result.is_empty(),
1014            "HTML comments should be skipped, so first heading after comment should be valid"
1015        );
1016    }
1017
1018    #[test]
1019    fn test_heading_with_special_characters() {
1020        let rule = MD041FirstLineHeading::default();
1021
1022        // Heading with special characters and formatting
1023        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1024        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025        let result = rule.check(&ctx).unwrap();
1026        assert!(
1027            result.is_empty(),
1028            "Expected no warnings for heading with inline formatting"
1029        );
1030    }
1031
1032    #[test]
1033    fn test_level_configuration() {
1034        // Test various level configurations
1035        for level in 1..=6 {
1036            let rule = MD041FirstLineHeading::new(level, false);
1037
1038            // Correct level
1039            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1040            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1041            let result = rule.check(&ctx).unwrap();
1042            assert!(
1043                result.is_empty(),
1044                "Expected no warnings for correct level {level} heading"
1045            );
1046
1047            // Wrong level
1048            let wrong_level = if level == 1 { 2 } else { 1 };
1049            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1050            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1051            let result = rule.check(&ctx).unwrap();
1052            assert_eq!(result.len(), 1);
1053            assert!(result[0].message.contains(&format!("level {level} heading")));
1054        }
1055    }
1056
1057    #[test]
1058    fn test_issue_152_multiline_html_heading() {
1059        let rule = MD041FirstLineHeading::default();
1060
1061        // Multi-line HTML h1 heading (should pass - issue #152)
1062        let content = "<h1>\nSome text\n</h1>";
1063        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1064        let result = rule.check(&ctx).unwrap();
1065        assert!(
1066            result.is_empty(),
1067            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_multiline_html_heading_with_attributes() {
1073        let rule = MD041FirstLineHeading::default();
1074
1075        // Multi-line HTML heading with attributes
1076        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1077        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078        let result = rule.check(&ctx).unwrap();
1079        assert!(
1080            result.is_empty(),
1081            "Multi-line HTML heading with attributes should be recognized"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_multiline_html_heading_wrong_level() {
1087        let rule = MD041FirstLineHeading::default();
1088
1089        // Multi-line HTML h2 heading (should fail with level 1 requirement)
1090        let content = "<h2>\nSome text\n</h2>";
1091        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092        let result = rule.check(&ctx).unwrap();
1093        assert_eq!(result.len(), 1);
1094        assert!(result[0].message.contains("level 1 heading"));
1095    }
1096
1097    #[test]
1098    fn test_multiline_html_heading_with_content_after() {
1099        let rule = MD041FirstLineHeading::default();
1100
1101        // Multi-line HTML heading followed by content
1102        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104        let result = rule.check(&ctx).unwrap();
1105        assert!(
1106            result.is_empty(),
1107            "Multi-line HTML heading followed by content should be valid"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_multiline_html_heading_incomplete() {
1113        let rule = MD041FirstLineHeading::default();
1114
1115        // Incomplete multi-line HTML heading (missing closing tag)
1116        let content = "<h1>\nSome text\n\nMore content without closing tag";
1117        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118        let result = rule.check(&ctx).unwrap();
1119        assert_eq!(result.len(), 1);
1120        assert!(result[0].message.contains("level 1 heading"));
1121    }
1122
1123    #[test]
1124    fn test_singleline_html_heading_still_works() {
1125        let rule = MD041FirstLineHeading::default();
1126
1127        // Single-line HTML heading should still work
1128        let content = "<h1>My Document</h1>\n\nContent.";
1129        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130        let result = rule.check(&ctx).unwrap();
1131        assert!(
1132            result.is_empty(),
1133            "Single-line HTML headings should still be recognized"
1134        );
1135    }
1136
1137    #[test]
1138    fn test_multiline_html_heading_with_nested_tags() {
1139        let rule = MD041FirstLineHeading::default();
1140
1141        // Multi-line HTML heading with nested tags
1142        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144        let result = rule.check(&ctx).unwrap();
1145        assert!(
1146            result.is_empty(),
1147            "Multi-line HTML heading with nested tags should be recognized"
1148        );
1149    }
1150
1151    #[test]
1152    fn test_multiline_html_heading_various_levels() {
1153        // Test multi-line headings at different levels
1154        for level in 1..=6 {
1155            let rule = MD041FirstLineHeading::new(level, false);
1156
1157            // Correct level multi-line
1158            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1159            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1160            let result = rule.check(&ctx).unwrap();
1161            assert!(
1162                result.is_empty(),
1163                "Multi-line HTML heading at level {level} should be recognized"
1164            );
1165
1166            // Wrong level multi-line
1167            let wrong_level = if level == 1 { 2 } else { 1 };
1168            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1169            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1170            let result = rule.check(&ctx).unwrap();
1171            assert_eq!(result.len(), 1);
1172            assert!(result[0].message.contains(&format!("level {level} heading")));
1173        }
1174    }
1175
1176    #[test]
1177    fn test_issue_152_nested_heading_spans_many_lines() {
1178        let rule = MD041FirstLineHeading::default();
1179
1180        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>";
1181        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182        let result = rule.check(&ctx).unwrap();
1183        assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1184    }
1185
1186    #[test]
1187    fn test_issue_152_picture_tag_heading() {
1188        let rule = MD041FirstLineHeading::default();
1189
1190        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>";
1191        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192        let result = rule.check(&ctx).unwrap();
1193        assert!(
1194            result.is_empty(),
1195            "Picture tag inside multi-line HTML heading should be recognized"
1196        );
1197    }
1198
1199    #[test]
1200    fn test_badge_images_before_heading() {
1201        let rule = MD041FirstLineHeading::default();
1202
1203        // Single badge before heading
1204        let content = "![badge](https://img.shields.io/badge/test-passing-green)\n\n# My Project";
1205        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206        let result = rule.check(&ctx).unwrap();
1207        assert!(result.is_empty(), "Badge image should be skipped");
1208
1209        // Multiple badges on one line
1210        let content = "![badge1](url1) ![badge2](url2)\n\n# My Project";
1211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212        let result = rule.check(&ctx).unwrap();
1213        assert!(result.is_empty(), "Multiple badges should be skipped");
1214
1215        // Linked badge (clickable)
1216        let content = "[![badge](https://img.shields.io/badge/test-pass-green)](https://example.com)\n\n# My Project";
1217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218        let result = rule.check(&ctx).unwrap();
1219        assert!(result.is_empty(), "Linked badge should be skipped");
1220    }
1221
1222    #[test]
1223    fn test_multiple_badge_lines_before_heading() {
1224        let rule = MD041FirstLineHeading::default();
1225
1226        // Multiple lines of badges
1227        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";
1228        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229        let result = rule.check(&ctx).unwrap();
1230        assert!(result.is_empty(), "Multiple badge lines should be skipped");
1231    }
1232
1233    #[test]
1234    fn test_badges_without_heading_still_warns() {
1235        let rule = MD041FirstLineHeading::default();
1236
1237        // Badges followed by paragraph (not heading)
1238        let content = "![badge](url)\n\nThis is not a heading.";
1239        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240        let result = rule.check(&ctx).unwrap();
1241        assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1242    }
1243
1244    #[test]
1245    fn test_mixed_content_not_badge_line() {
1246        let rule = MD041FirstLineHeading::default();
1247
1248        // Image with text is not a badge line
1249        let content = "![badge](url) Some text here\n\n# Heading";
1250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251        let result = rule.check(&ctx).unwrap();
1252        assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1253    }
1254
1255    #[test]
1256    fn test_is_badge_image_line_unit() {
1257        // Unit tests for is_badge_image_line
1258        assert!(MD041FirstLineHeading::is_badge_image_line("![badge](url)"));
1259        assert!(MD041FirstLineHeading::is_badge_image_line("[![badge](img)](link)"));
1260        assert!(MD041FirstLineHeading::is_badge_image_line("![a](b) ![c](d)"));
1261        assert!(MD041FirstLineHeading::is_badge_image_line("[![a](b)](c) [![d](e)](f)"));
1262
1263        // Not badge lines
1264        assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1265        assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1266        assert!(!MD041FirstLineHeading::is_badge_image_line("![badge](url) text"));
1267        assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1268    }
1269
1270    // Integration tests for MkDocs anchor line detection (issue #365)
1271    // Unit tests for is_mkdocs_anchor_line are in utils/mkdocs_attr_list.rs
1272
1273    #[test]
1274    fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1275        let rule = MD041FirstLineHeading::default();
1276
1277        // MkDocs anchor line before heading in MkDocs flavor (should pass)
1278        let content = "[](){ #example }\n# Title";
1279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1280        let result = rule.check(&ctx).unwrap();
1281        assert!(
1282            result.is_empty(),
1283            "MkDocs anchor line should be skipped in MkDocs flavor"
1284        );
1285    }
1286
1287    #[test]
1288    fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1289        let rule = MD041FirstLineHeading::default();
1290
1291        // MkDocs anchor line before heading in Standard flavor (should fail)
1292        let content = "[](){ #example }\n# Title";
1293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294        let result = rule.check(&ctx).unwrap();
1295        assert_eq!(
1296            result.len(),
1297            1,
1298            "MkDocs anchor line should NOT be skipped in Standard flavor"
1299        );
1300    }
1301
1302    #[test]
1303    fn test_multiple_mkdocs_anchors_before_heading() {
1304        let rule = MD041FirstLineHeading::default();
1305
1306        // Multiple MkDocs anchor lines before heading in MkDocs flavor
1307        let content = "[](){ #first }\n[](){ #second }\n# Title";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1309        let result = rule.check(&ctx).unwrap();
1310        assert!(
1311            result.is_empty(),
1312            "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1313        );
1314    }
1315
1316    #[test]
1317    fn test_mkdocs_anchor_with_front_matter() {
1318        let rule = MD041FirstLineHeading::default();
1319
1320        // MkDocs anchor after front matter
1321        let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1323        let result = rule.check(&ctx).unwrap();
1324        assert!(
1325            result.is_empty(),
1326            "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_mkdocs_anchor_kramdown_style() {
1332        let rule = MD041FirstLineHeading::default();
1333
1334        // Kramdown-style with colon
1335        let content = "[](){: #anchor }\n# Title";
1336        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1337        let result = rule.check(&ctx).unwrap();
1338        assert!(
1339            result.is_empty(),
1340            "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1341        );
1342    }
1343
1344    #[test]
1345    fn test_mkdocs_anchor_without_heading_still_warns() {
1346        let rule = MD041FirstLineHeading::default();
1347
1348        // MkDocs anchor followed by non-heading content
1349        let content = "[](){ #anchor }\nThis is not a heading.";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1351        let result = rule.check(&ctx).unwrap();
1352        assert_eq!(
1353            result.len(),
1354            1,
1355            "MkDocs anchor followed by non-heading should still trigger MD041"
1356        );
1357    }
1358
1359    #[test]
1360    fn test_mkdocs_anchor_with_html_comment() {
1361        let rule = MD041FirstLineHeading::default();
1362
1363        // MkDocs anchor combined with HTML comment before heading
1364        let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1366        let result = rule.check(&ctx).unwrap();
1367        assert!(
1368            result.is_empty(),
1369            "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1370        );
1371    }
1372
1373    // Tests for auto-fix functionality (issue #359)
1374
1375    #[test]
1376    fn test_fix_disabled_by_default() {
1377        use crate::rule::Rule;
1378        let rule = MD041FirstLineHeading::default();
1379
1380        // Fix should not change content when disabled
1381        let content = "## Wrong Level\n\nContent.";
1382        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383        let fixed = rule.fix(&ctx).unwrap();
1384        assert_eq!(fixed, content, "Fix should not change content when disabled");
1385    }
1386
1387    #[test]
1388    fn test_fix_wrong_heading_level() {
1389        use crate::rule::Rule;
1390        let rule = MD041FirstLineHeading {
1391            level: 1,
1392            front_matter_title: false,
1393            front_matter_title_pattern: None,
1394            fix_enabled: true,
1395        };
1396
1397        // ## should become #
1398        let content = "## Wrong Level\n\nContent.\n";
1399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400        let fixed = rule.fix(&ctx).unwrap();
1401        assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1402    }
1403
1404    #[test]
1405    fn test_fix_heading_after_preamble() {
1406        use crate::rule::Rule;
1407        let rule = MD041FirstLineHeading {
1408            level: 1,
1409            front_matter_title: false,
1410            front_matter_title_pattern: None,
1411            fix_enabled: true,
1412        };
1413
1414        // Heading after blank lines should be moved up
1415        let content = "\n\n# Title\n\nContent.\n";
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417        let fixed = rule.fix(&ctx).unwrap();
1418        assert!(
1419            fixed.starts_with("# Title\n"),
1420            "Heading should be moved to first line, got: {fixed}"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_fix_heading_after_html_comment() {
1426        use crate::rule::Rule;
1427        let rule = MD041FirstLineHeading {
1428            level: 1,
1429            front_matter_title: false,
1430            front_matter_title_pattern: None,
1431            fix_enabled: true,
1432        };
1433
1434        // Heading after HTML comment should be moved up
1435        let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437        let fixed = rule.fix(&ctx).unwrap();
1438        assert!(
1439            fixed.starts_with("# Title\n"),
1440            "Heading should be moved above comment, got: {fixed}"
1441        );
1442    }
1443
1444    #[test]
1445    fn test_fix_heading_level_and_move() {
1446        use crate::rule::Rule;
1447        let rule = MD041FirstLineHeading {
1448            level: 1,
1449            front_matter_title: false,
1450            front_matter_title_pattern: None,
1451            fix_enabled: true,
1452        };
1453
1454        // Heading with wrong level after preamble should be fixed and moved
1455        let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457        let fixed = rule.fix(&ctx).unwrap();
1458        assert!(
1459            fixed.starts_with("# Wrong Level\n"),
1460            "Heading should be fixed and moved, got: {fixed}"
1461        );
1462    }
1463
1464    #[test]
1465    fn test_fix_with_front_matter() {
1466        use crate::rule::Rule;
1467        let rule = MD041FirstLineHeading {
1468            level: 1,
1469            front_matter_title: false,
1470            front_matter_title_pattern: None,
1471            fix_enabled: true,
1472        };
1473
1474        // Heading after front matter and preamble
1475        let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477        let fixed = rule.fix(&ctx).unwrap();
1478        assert!(
1479            fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1480            "Heading should be right after front matter, got: {fixed}"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_fix_cannot_fix_no_heading() {
1486        use crate::rule::Rule;
1487        let rule = MD041FirstLineHeading {
1488            level: 1,
1489            front_matter_title: false,
1490            front_matter_title_pattern: None,
1491            fix_enabled: true,
1492        };
1493
1494        // No heading in document - cannot fix
1495        let content = "Just some text.\n\nMore text.\n";
1496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497        let fixed = rule.fix(&ctx).unwrap();
1498        assert_eq!(fixed, content, "Should not change content when no heading exists");
1499    }
1500
1501    #[test]
1502    fn test_fix_cannot_fix_content_before_heading() {
1503        use crate::rule::Rule;
1504        let rule = MD041FirstLineHeading {
1505            level: 1,
1506            front_matter_title: false,
1507            front_matter_title_pattern: None,
1508            fix_enabled: true,
1509        };
1510
1511        // Real content before heading - cannot safely fix
1512        let content = "Some intro text.\n\n# Title\n\nContent.\n";
1513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514        let fixed = rule.fix(&ctx).unwrap();
1515        assert_eq!(
1516            fixed, content,
1517            "Should not change content when real content exists before heading"
1518        );
1519    }
1520
1521    #[test]
1522    fn test_fix_already_correct() {
1523        use crate::rule::Rule;
1524        let rule = MD041FirstLineHeading {
1525            level: 1,
1526            front_matter_title: false,
1527            front_matter_title_pattern: None,
1528            fix_enabled: true,
1529        };
1530
1531        // Already correct - no changes needed
1532        let content = "# Title\n\nContent.\n";
1533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534        let fixed = rule.fix(&ctx).unwrap();
1535        assert_eq!(fixed, content, "Should not change already correct content");
1536    }
1537
1538    #[test]
1539    fn test_fix_setext_heading_removes_underline() {
1540        use crate::rule::Rule;
1541        let rule = MD041FirstLineHeading {
1542            level: 1,
1543            front_matter_title: false,
1544            front_matter_title_pattern: None,
1545            fix_enabled: true,
1546        };
1547
1548        // Setext heading (level 2 with --- underline)
1549        let content = "Wrong Level\n-----------\n\nContent.\n";
1550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551        let fixed = rule.fix(&ctx).unwrap();
1552        assert_eq!(
1553            fixed, "# Wrong Level\n\nContent.\n",
1554            "Setext heading should be converted to ATX and underline removed"
1555        );
1556    }
1557
1558    #[test]
1559    fn test_fix_setext_h1_heading() {
1560        use crate::rule::Rule;
1561        let rule = MD041FirstLineHeading {
1562            level: 1,
1563            front_matter_title: false,
1564            front_matter_title_pattern: None,
1565            fix_enabled: true,
1566        };
1567
1568        // Setext h1 heading (=== underline) after preamble - needs move but not level fix
1569        let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
1570        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571        let fixed = rule.fix(&ctx).unwrap();
1572        assert_eq!(
1573            fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
1574            "Setext h1 should be moved and converted to ATX"
1575        );
1576    }
1577
1578    #[test]
1579    fn test_html_heading_not_claimed_fixable() {
1580        use crate::rule::Rule;
1581        let rule = MD041FirstLineHeading {
1582            level: 1,
1583            front_matter_title: false,
1584            front_matter_title_pattern: None,
1585            fix_enabled: true,
1586        };
1587
1588        // HTML heading - should NOT be claimed as fixable (we can't convert HTML to ATX)
1589        let content = "<h2>Title</h2>\n\nContent.\n";
1590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591        let warnings = rule.check(&ctx).unwrap();
1592        assert_eq!(warnings.len(), 1);
1593        assert!(
1594            warnings[0].fix.is_none(),
1595            "HTML heading should not be claimed as fixable"
1596        );
1597    }
1598
1599    #[test]
1600    fn test_no_heading_not_claimed_fixable() {
1601        use crate::rule::Rule;
1602        let rule = MD041FirstLineHeading {
1603            level: 1,
1604            front_matter_title: false,
1605            front_matter_title_pattern: None,
1606            fix_enabled: true,
1607        };
1608
1609        // No heading in document - should NOT be claimed as fixable
1610        let content = "Just some text.\n\nMore text.\n";
1611        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612        let warnings = rule.check(&ctx).unwrap();
1613        assert_eq!(warnings.len(), 1);
1614        assert!(
1615            warnings[0].fix.is_none(),
1616            "Document without heading should not be claimed as fixable"
1617        );
1618    }
1619
1620    #[test]
1621    fn test_content_before_heading_not_claimed_fixable() {
1622        use crate::rule::Rule;
1623        let rule = MD041FirstLineHeading {
1624            level: 1,
1625            front_matter_title: false,
1626            front_matter_title_pattern: None,
1627            fix_enabled: true,
1628        };
1629
1630        // Content before heading - should NOT be claimed as fixable
1631        let content = "Intro text.\n\n## Heading\n\nMore.\n";
1632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633        let warnings = rule.check(&ctx).unwrap();
1634        assert_eq!(warnings.len(), 1);
1635        assert!(
1636            warnings[0].fix.is_none(),
1637            "Document with content before heading should not be claimed as fixable"
1638        );
1639    }
1640}