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