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