rumdl_lib/rules/md041_first_line_heading/
mod.rs

1mod md041_config;
2
3pub use md041_config::MD041Config;
4
5use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
6use crate::rules::front_matter_utils::FrontMatterUtils;
7use crate::utils::range_utils::calculate_line_range;
8use crate::utils::regex_cache::HTML_HEADING_PATTERN;
9use regex::Regex;
10
11/// Rule MD041: First line in file should be a top-level heading
12///
13/// See [docs/md041.md](../../docs/md041.md) for full documentation, configuration, and examples.
14
15#[derive(Clone)]
16pub struct MD041FirstLineHeading {
17    pub level: usize,
18    pub front_matter_title: bool,
19    pub front_matter_title_pattern: Option<Regex>,
20}
21
22impl Default for MD041FirstLineHeading {
23    fn default() -> Self {
24        Self {
25            level: 1,
26            front_matter_title: true,
27            front_matter_title_pattern: None,
28        }
29    }
30}
31
32impl MD041FirstLineHeading {
33    pub fn new(level: usize, front_matter_title: bool) -> Self {
34        Self {
35            level,
36            front_matter_title,
37            front_matter_title_pattern: None,
38        }
39    }
40
41    pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>) -> Self {
42        let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
43            Ok(regex) => Some(regex),
44            Err(e) => {
45                log::warn!("Invalid front_matter_title_pattern regex: {e}");
46                None
47            }
48        });
49
50        Self {
51            level,
52            front_matter_title,
53            front_matter_title_pattern,
54        }
55    }
56
57    fn has_front_matter_title(&self, content: &str) -> bool {
58        if !self.front_matter_title {
59            return false;
60        }
61
62        // If we have a custom pattern, use it to search front matter content
63        if let Some(ref pattern) = self.front_matter_title_pattern {
64            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
65            for line in front_matter_lines {
66                if pattern.is_match(line) {
67                    return true;
68                }
69            }
70            return false;
71        }
72
73        // Default behavior: check for "title:" field
74        FrontMatterUtils::has_front_matter_field(content, "title:")
75    }
76
77    /// Check if a line is a non-content token that should be skipped
78    fn is_non_content_line(line: &str) -> bool {
79        let trimmed = line.trim();
80
81        // Skip reference definitions
82        if trimmed.starts_with('[') && trimmed.contains("]: ") {
83            return true;
84        }
85
86        // Skip abbreviation definitions
87        if trimmed.starts_with('*') && trimmed.contains("]: ") {
88            return true;
89        }
90
91        // Skip badge/shield images - common pattern at top of READMEs
92        // Matches: ![badge](url) or [![badge](url)](url)
93        if Self::is_badge_image_line(trimmed) {
94            return true;
95        }
96
97        false
98    }
99
100    /// Check if a line consists only of badge/shield images
101    /// Common patterns:
102    /// - `![badge](url)`
103    /// - `[![badge](url)](url)` (linked badge)
104    /// - Multiple badges on one line
105    fn is_badge_image_line(line: &str) -> bool {
106        if line.is_empty() {
107            return false;
108        }
109
110        // Must start with image syntax
111        if !line.starts_with('!') && !line.starts_with('[') {
112            return false;
113        }
114
115        // Check if line contains only image/link patterns and whitespace
116        let mut remaining = line;
117        while !remaining.is_empty() {
118            remaining = remaining.trim_start();
119            if remaining.is_empty() {
120                break;
121            }
122
123            // Linked image: [![alt](img-url)](link-url)
124            if remaining.starts_with("[![") {
125                if let Some(end) = Self::find_linked_image_end(remaining) {
126                    remaining = &remaining[end..];
127                    continue;
128                }
129                return false;
130            }
131
132            // Simple image: ![alt](url)
133            if remaining.starts_with("![") {
134                if let Some(end) = Self::find_image_end(remaining) {
135                    remaining = &remaining[end..];
136                    continue;
137                }
138                return false;
139            }
140
141            // Not an image pattern
142            return false;
143        }
144
145        true
146    }
147
148    /// Find the end of an image pattern ![alt](url)
149    fn find_image_end(s: &str) -> Option<usize> {
150        if !s.starts_with("![") {
151            return None;
152        }
153        // Find ]( after ![
154        let alt_end = s[2..].find("](")?;
155        let paren_start = 2 + alt_end + 2; // Position after ](
156        // Find closing )
157        let paren_end = s[paren_start..].find(')')?;
158        Some(paren_start + paren_end + 1)
159    }
160
161    /// Find the end of a linked image pattern [![alt](img-url)](link-url)
162    fn find_linked_image_end(s: &str) -> Option<usize> {
163        if !s.starts_with("[![") {
164            return None;
165        }
166        // Find the inner image first
167        let inner_end = Self::find_image_end(&s[1..])?;
168        let after_inner = 1 + inner_end;
169        // Should be followed by ](url)
170        if !s[after_inner..].starts_with("](") {
171            return None;
172        }
173        let link_start = after_inner + 2;
174        let link_end = s[link_start..].find(')')?;
175        Some(link_start + link_end + 1)
176    }
177
178    /// Check if a line is an HTML heading using the centralized HTML parser
179    fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
180        // Check for single-line HTML heading using regex (fast path)
181        let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
182        if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
183            && let Some(h_level) = captures.get(1)
184            && h_level.as_str().parse::<usize>().unwrap_or(0) == level
185        {
186            return true;
187        }
188
189        // Use centralized HTML parser for multi-line headings
190        let html_tags = ctx.html_tags();
191        let target_tag = format!("h{level}");
192
193        // Find opening tag on first line
194        let opening_index = html_tags.iter().position(|tag| {
195            tag.line == first_line_idx + 1 // HtmlTag uses 1-indexed lines
196                && tag.tag_name == target_tag
197                && !tag.is_closing
198        });
199
200        let Some(open_idx) = opening_index else {
201            return false;
202        };
203
204        // Walk HTML tags to find the corresponding closing tag, allowing arbitrary nesting depth.
205        // This avoids brittle line-count heuristics and handles long headings with nested content.
206        let mut depth = 1usize;
207        for tag in html_tags.iter().skip(open_idx + 1) {
208            // Ignore tags that appear before the first heading line (possible when multiple tags share a line)
209            if tag.line <= first_line_idx + 1 {
210                continue;
211            }
212
213            if tag.tag_name == target_tag {
214                if tag.is_closing {
215                    depth -= 1;
216                    if depth == 0 {
217                        return true;
218                    }
219                } else if !tag.is_self_closing {
220                    depth += 1;
221                }
222            }
223        }
224
225        false
226    }
227}
228
229impl Rule for MD041FirstLineHeading {
230    fn name(&self) -> &'static str {
231        "MD041"
232    }
233
234    fn description(&self) -> &'static str {
235        "First line in file should be a top level heading"
236    }
237
238    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
239        let mut warnings = Vec::new();
240
241        // Check if we should skip this file
242        if self.should_skip(ctx) {
243            return Ok(warnings);
244        }
245
246        // Find the first non-blank line after front matter using cached info
247        let mut first_content_line_num = None;
248        let mut skip_lines = 0;
249
250        // Check for front matter
251        if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
252            // Skip front matter
253            for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
254                if line_info.content(ctx.content).trim() == "---" {
255                    skip_lines = idx + 1;
256                    break;
257                }
258            }
259        }
260
261        for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
262            let line_content = line_info.content(ctx.content).trim();
263            // Skip ESM blocks in MDX files (import/export statements)
264            if line_info.in_esm_block {
265                continue;
266            }
267            // Skip HTML comments - they are non-visible and should not affect MD041
268            if line_info.in_html_comment {
269                continue;
270            }
271            if !line_content.is_empty() && !Self::is_non_content_line(line_info.content(ctx.content)) {
272                first_content_line_num = Some(line_num);
273                break;
274            }
275        }
276
277        if first_content_line_num.is_none() {
278            // No non-blank lines after front matter
279            return Ok(warnings);
280        }
281
282        let first_line_idx = first_content_line_num.unwrap();
283
284        // Check if the first non-blank line is a heading of the required level
285        let first_line_info = &ctx.lines[first_line_idx];
286        let is_correct_heading = if let Some(heading) = &first_line_info.heading {
287            heading.level as usize == self.level
288        } else {
289            // Check for HTML heading (both single-line and multi-line)
290            Self::is_html_heading(ctx, first_line_idx, self.level)
291        };
292
293        if !is_correct_heading {
294            // Calculate precise character range for the entire first line
295            let first_line = first_line_idx + 1; // Convert to 1-indexed
296            let first_line_content = first_line_info.content(ctx.content);
297            let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
298
299            warnings.push(LintWarning {
300                rule_name: Some(self.name().to_string()),
301                line: start_line,
302                column: start_col,
303                end_line,
304                end_column: end_col,
305                message: format!("First line in file should be a level {} heading", self.level),
306                severity: Severity::Warning,
307                fix: None, // MD041 no longer provides auto-fix suggestions
308            });
309        }
310        Ok(warnings)
311    }
312
313    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
314        // MD041 should not auto-fix - adding content/titles is a decision that should be made by the document author
315        // This rule now only detects and warns about missing titles, but does not automatically add them
316        Ok(ctx.content.to_string())
317    }
318
319    /// Check if this rule should be skipped
320    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
321        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
322        // These files are composition/routing metadata, not standalone content.
323        // Example: A file containing only "{{#include ../../README.md}}" is a
324        // pointer to content, not content itself, and shouldn't need a heading.
325        let only_directives = !ctx.content.is_empty()
326            && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
327                let t = l.trim();
328                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
329                (t.starts_with("{{#") && t.ends_with("}}"))
330                        // HTML comments often accompany directives
331                        || (t.starts_with("<!--") && t.ends_with("-->"))
332            });
333
334        ctx.content.is_empty()
335            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
336            || only_directives
337    }
338
339    fn as_any(&self) -> &dyn std::any::Any {
340        self
341    }
342
343    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
344    where
345        Self: Sized,
346    {
347        // Load config using serde with kebab-case support
348        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
349
350        let use_front_matter = !md041_config.front_matter_title.is_empty();
351
352        Box::new(MD041FirstLineHeading::with_pattern(
353            md041_config.level.as_usize(),
354            use_front_matter,
355            md041_config.front_matter_title_pattern,
356        ))
357    }
358
359    fn default_config_section(&self) -> Option<(String, toml::Value)> {
360        Some((
361            "MD041".to_string(),
362            toml::toml! {
363                level = 1
364                front-matter-title = "title"
365                front-matter-title-pattern = ""
366            }
367            .into(),
368        ))
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::lint_context::LintContext;
376
377    #[test]
378    fn test_first_line_is_heading_correct_level() {
379        let rule = MD041FirstLineHeading::default();
380
381        // First line is a level 1 heading (should pass)
382        let content = "# My Document\n\nSome content here.";
383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384        let result = rule.check(&ctx).unwrap();
385        assert!(
386            result.is_empty(),
387            "Expected no warnings when first line is a level 1 heading"
388        );
389    }
390
391    #[test]
392    fn test_first_line_is_heading_wrong_level() {
393        let rule = MD041FirstLineHeading::default();
394
395        // First line is a level 2 heading (should fail with level 1 requirement)
396        let content = "## My Document\n\nSome content here.";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398        let result = rule.check(&ctx).unwrap();
399        assert_eq!(result.len(), 1);
400        assert_eq!(result[0].line, 1);
401        assert!(result[0].message.contains("level 1 heading"));
402    }
403
404    #[test]
405    fn test_first_line_not_heading() {
406        let rule = MD041FirstLineHeading::default();
407
408        // First line is plain text (should fail)
409        let content = "This is not a heading\n\n# This is a heading";
410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411        let result = rule.check(&ctx).unwrap();
412        assert_eq!(result.len(), 1);
413        assert_eq!(result[0].line, 1);
414        assert!(result[0].message.contains("level 1 heading"));
415    }
416
417    #[test]
418    fn test_empty_lines_before_heading() {
419        let rule = MD041FirstLineHeading::default();
420
421        // Empty lines before first heading (should pass - rule skips empty lines)
422        let content = "\n\n# My Document\n\nSome content.";
423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424        let result = rule.check(&ctx).unwrap();
425        assert!(
426            result.is_empty(),
427            "Expected no warnings when empty lines precede a valid heading"
428        );
429
430        // Empty lines before non-heading content (should fail)
431        let content = "\n\nNot a heading\n\nSome content.";
432        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433        let result = rule.check(&ctx).unwrap();
434        assert_eq!(result.len(), 1);
435        assert_eq!(result[0].line, 3); // First non-empty line
436        assert!(result[0].message.contains("level 1 heading"));
437    }
438
439    #[test]
440    fn test_front_matter_with_title() {
441        let rule = MD041FirstLineHeading::new(1, true);
442
443        // Front matter with title field (should pass)
444        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447        assert!(
448            result.is_empty(),
449            "Expected no warnings when front matter has title field"
450        );
451    }
452
453    #[test]
454    fn test_front_matter_without_title() {
455        let rule = MD041FirstLineHeading::new(1, true);
456
457        // Front matter without title field (should fail)
458        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let result = rule.check(&ctx).unwrap();
461        assert_eq!(result.len(), 1);
462        assert_eq!(result[0].line, 6); // First content line after front matter
463    }
464
465    #[test]
466    fn test_front_matter_disabled() {
467        let rule = MD041FirstLineHeading::new(1, false);
468
469        // Front matter with title field but front_matter_title is false (should fail)
470        let content = "---\ntitle: My Document\n---\n\nSome content here.";
471        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472        let result = rule.check(&ctx).unwrap();
473        assert_eq!(result.len(), 1);
474        assert_eq!(result[0].line, 5); // First content line after front matter
475    }
476
477    #[test]
478    fn test_html_comments_before_heading() {
479        let rule = MD041FirstLineHeading::default();
480
481        // HTML comment before heading (should pass - comments are skipped, issue #155)
482        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484        let result = rule.check(&ctx).unwrap();
485        assert!(
486            result.is_empty(),
487            "HTML comments should be skipped when checking for first heading"
488        );
489    }
490
491    #[test]
492    fn test_multiline_html_comment_before_heading() {
493        let rule = MD041FirstLineHeading::default();
494
495        // Multi-line HTML comment before heading (should pass - issue #155)
496        let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498        let result = rule.check(&ctx).unwrap();
499        assert!(
500            result.is_empty(),
501            "Multi-line HTML comments should be skipped when checking for first heading"
502        );
503    }
504
505    #[test]
506    fn test_html_comment_with_blank_lines_before_heading() {
507        let rule = MD041FirstLineHeading::default();
508
509        // HTML comment with blank lines before heading (should pass - issue #155)
510        let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512        let result = rule.check(&ctx).unwrap();
513        assert!(
514            result.is_empty(),
515            "HTML comments with blank lines should be skipped when checking for first heading"
516        );
517    }
518
519    #[test]
520    fn test_html_comment_before_html_heading() {
521        let rule = MD041FirstLineHeading::default();
522
523        // HTML comment before HTML heading (should pass - issue #155)
524        let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526        let result = rule.check(&ctx).unwrap();
527        assert!(
528            result.is_empty(),
529            "HTML comments should be skipped before HTML headings"
530        );
531    }
532
533    #[test]
534    fn test_document_with_only_html_comments() {
535        let rule = MD041FirstLineHeading::default();
536
537        // Document with only HTML comments (should pass - no warnings for comment-only files)
538        let content = "<!-- This is a comment -->\n<!-- Another comment -->";
539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540        let result = rule.check(&ctx).unwrap();
541        assert!(
542            result.is_empty(),
543            "Documents with only HTML comments should not trigger MD041"
544        );
545    }
546
547    #[test]
548    fn test_html_comment_followed_by_non_heading() {
549        let rule = MD041FirstLineHeading::default();
550
551        // HTML comment followed by non-heading content (should still fail - issue #155)
552        let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let result = rule.check(&ctx).unwrap();
555        assert_eq!(
556            result.len(),
557            1,
558            "HTML comment followed by non-heading should still trigger MD041"
559        );
560        assert_eq!(
561            result[0].line, 2,
562            "Warning should be on the first non-comment, non-heading line"
563        );
564    }
565
566    #[test]
567    fn test_multiple_html_comments_before_heading() {
568        let rule = MD041FirstLineHeading::default();
569
570        // Multiple HTML comments before heading (should pass - issue #155)
571        let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
572        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573        let result = rule.check(&ctx).unwrap();
574        assert!(
575            result.is_empty(),
576            "Multiple HTML comments should all be skipped before heading"
577        );
578    }
579
580    #[test]
581    fn test_html_comment_with_wrong_level_heading() {
582        let rule = MD041FirstLineHeading::default();
583
584        // HTML comment followed by wrong-level heading (should fail - issue #155)
585        let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx).unwrap();
588        assert_eq!(
589            result.len(),
590            1,
591            "HTML comment followed by wrong-level heading should still trigger MD041"
592        );
593        assert!(
594            result[0].message.contains("level 1 heading"),
595            "Should require level 1 heading"
596        );
597    }
598
599    #[test]
600    fn test_html_comment_mixed_with_reference_definitions() {
601        let rule = MD041FirstLineHeading::default();
602
603        // HTML comment mixed with reference definitions before heading (should pass - issue #155)
604        let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606        let result = rule.check(&ctx).unwrap();
607        assert!(
608            result.is_empty(),
609            "HTML comments and reference definitions should both be skipped before heading"
610        );
611    }
612
613    #[test]
614    fn test_html_comment_after_front_matter() {
615        let rule = MD041FirstLineHeading::default();
616
617        // HTML comment after front matter, before heading (should pass - issue #155)
618        let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620        let result = rule.check(&ctx).unwrap();
621        assert!(
622            result.is_empty(),
623            "HTML comments after front matter should be skipped before heading"
624        );
625    }
626
627    #[test]
628    fn test_html_comment_not_at_start_should_not_affect_rule() {
629        let rule = MD041FirstLineHeading::default();
630
631        // HTML comment in middle of document should not affect MD041 check
632        let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634        let result = rule.check(&ctx).unwrap();
635        assert!(
636            result.is_empty(),
637            "HTML comments in middle of document should not affect MD041 (only first content matters)"
638        );
639    }
640
641    #[test]
642    fn test_multiline_html_comment_followed_by_non_heading() {
643        let rule = MD041FirstLineHeading::default();
644
645        // Multi-line HTML comment followed by non-heading (should still fail - issue #155)
646        let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648        let result = rule.check(&ctx).unwrap();
649        assert_eq!(
650            result.len(),
651            1,
652            "Multi-line HTML comment followed by non-heading should still trigger MD041"
653        );
654        assert_eq!(
655            result[0].line, 5,
656            "Warning should be on the first non-comment, non-heading line"
657        );
658    }
659
660    #[test]
661    fn test_different_heading_levels() {
662        // Test with level 2 requirement
663        let rule = MD041FirstLineHeading::new(2, false);
664
665        let content = "## Second Level Heading\n\nContent.";
666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667        let result = rule.check(&ctx).unwrap();
668        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
669
670        // Wrong level
671        let content = "# First Level Heading\n\nContent.";
672        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673        let result = rule.check(&ctx).unwrap();
674        assert_eq!(result.len(), 1);
675        assert!(result[0].message.contains("level 2 heading"));
676    }
677
678    #[test]
679    fn test_setext_headings() {
680        let rule = MD041FirstLineHeading::default();
681
682        // Setext style level 1 heading (should pass)
683        let content = "My Document\n===========\n\nContent.";
684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685        let result = rule.check(&ctx).unwrap();
686        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
687
688        // Setext style level 2 heading (should fail with level 1 requirement)
689        let content = "My Document\n-----------\n\nContent.";
690        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691        let result = rule.check(&ctx).unwrap();
692        assert_eq!(result.len(), 1);
693        assert!(result[0].message.contains("level 1 heading"));
694    }
695
696    #[test]
697    fn test_empty_document() {
698        let rule = MD041FirstLineHeading::default();
699
700        // Empty document (should pass - no warnings)
701        let content = "";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let result = rule.check(&ctx).unwrap();
704        assert!(result.is_empty(), "Expected no warnings for empty document");
705    }
706
707    #[test]
708    fn test_whitespace_only_document() {
709        let rule = MD041FirstLineHeading::default();
710
711        // Document with only whitespace (should pass - no warnings)
712        let content = "   \n\n   \t\n";
713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714        let result = rule.check(&ctx).unwrap();
715        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
716    }
717
718    #[test]
719    fn test_front_matter_then_whitespace() {
720        let rule = MD041FirstLineHeading::default();
721
722        // Front matter followed by only whitespace (should pass - no warnings)
723        let content = "---\ntitle: Test\n---\n\n   \n\n";
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            "Expected no warnings when no content after front matter"
729        );
730    }
731
732    #[test]
733    fn test_multiple_front_matter_types() {
734        let rule = MD041FirstLineHeading::new(1, true);
735
736        // TOML front matter with title (should fail - rule only checks for "title:" pattern)
737        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.check(&ctx).unwrap();
740        assert_eq!(result.len(), 1);
741        assert!(result[0].message.contains("level 1 heading"));
742
743        // JSON front matter with title (should fail - doesn't have "title:" pattern, has "\"title\":")
744        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
745        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746        let result = rule.check(&ctx).unwrap();
747        assert_eq!(result.len(), 1);
748        assert!(result[0].message.contains("level 1 heading"));
749
750        // YAML front matter with title field (standard case)
751        let content = "---\ntitle: My Document\n---\n\nContent.";
752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753        let result = rule.check(&ctx).unwrap();
754        assert!(
755            result.is_empty(),
756            "Expected no warnings for YAML front matter with title"
757        );
758
759        // Test mixed format edge case - YAML-style in TOML
760        let content = "+++\ntitle: My Document\n+++\n\nContent.";
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let result = rule.check(&ctx).unwrap();
763        assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
764    }
765
766    #[test]
767    fn test_malformed_front_matter() {
768        let rule = MD041FirstLineHeading::new(1, true);
769
770        // Malformed front matter with title
771        let content = "- --\ntitle: My Document\n- --\n\nContent.";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.check(&ctx).unwrap();
774        assert!(
775            result.is_empty(),
776            "Expected no warnings for malformed front matter with title"
777        );
778    }
779
780    #[test]
781    fn test_front_matter_with_heading() {
782        let rule = MD041FirstLineHeading::default();
783
784        // Front matter without title field followed by correct heading
785        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
786        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787        let result = rule.check(&ctx).unwrap();
788        assert!(
789            result.is_empty(),
790            "Expected no warnings when first line after front matter is correct heading"
791        );
792    }
793
794    #[test]
795    fn test_no_fix_suggestion() {
796        let rule = MD041FirstLineHeading::default();
797
798        // Check that NO fix suggestion is provided (MD041 is now detection-only)
799        let content = "Not a heading\n\nContent.";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801        let result = rule.check(&ctx).unwrap();
802        assert_eq!(result.len(), 1);
803        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
804    }
805
806    #[test]
807    fn test_complex_document_structure() {
808        let rule = MD041FirstLineHeading::default();
809
810        // Complex document with various elements - HTML comment should be skipped (issue #155)
811        let content =
812            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814        let result = rule.check(&ctx).unwrap();
815        assert!(
816            result.is_empty(),
817            "HTML comments should be skipped, so first heading after comment should be valid"
818        );
819    }
820
821    #[test]
822    fn test_heading_with_special_characters() {
823        let rule = MD041FirstLineHeading::default();
824
825        // Heading with special characters and formatting
826        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828        let result = rule.check(&ctx).unwrap();
829        assert!(
830            result.is_empty(),
831            "Expected no warnings for heading with inline formatting"
832        );
833    }
834
835    #[test]
836    fn test_level_configuration() {
837        // Test various level configurations
838        for level in 1..=6 {
839            let rule = MD041FirstLineHeading::new(level, false);
840
841            // Correct level
842            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
843            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
844            let result = rule.check(&ctx).unwrap();
845            assert!(
846                result.is_empty(),
847                "Expected no warnings for correct level {level} heading"
848            );
849
850            // Wrong level
851            let wrong_level = if level == 1 { 2 } else { 1 };
852            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
853            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
854            let result = rule.check(&ctx).unwrap();
855            assert_eq!(result.len(), 1);
856            assert!(result[0].message.contains(&format!("level {level} heading")));
857        }
858    }
859
860    #[test]
861    fn test_issue_152_multiline_html_heading() {
862        let rule = MD041FirstLineHeading::default();
863
864        // Multi-line HTML h1 heading (should pass - issue #152)
865        let content = "<h1>\nSome text\n</h1>";
866        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867        let result = rule.check(&ctx).unwrap();
868        assert!(
869            result.is_empty(),
870            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
871        );
872    }
873
874    #[test]
875    fn test_multiline_html_heading_with_attributes() {
876        let rule = MD041FirstLineHeading::default();
877
878        // Multi-line HTML heading with attributes
879        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
880        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881        let result = rule.check(&ctx).unwrap();
882        assert!(
883            result.is_empty(),
884            "Multi-line HTML heading with attributes should be recognized"
885        );
886    }
887
888    #[test]
889    fn test_multiline_html_heading_wrong_level() {
890        let rule = MD041FirstLineHeading::default();
891
892        // Multi-line HTML h2 heading (should fail with level 1 requirement)
893        let content = "<h2>\nSome text\n</h2>";
894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895        let result = rule.check(&ctx).unwrap();
896        assert_eq!(result.len(), 1);
897        assert!(result[0].message.contains("level 1 heading"));
898    }
899
900    #[test]
901    fn test_multiline_html_heading_with_content_after() {
902        let rule = MD041FirstLineHeading::default();
903
904        // Multi-line HTML heading followed by content
905        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
906        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907        let result = rule.check(&ctx).unwrap();
908        assert!(
909            result.is_empty(),
910            "Multi-line HTML heading followed by content should be valid"
911        );
912    }
913
914    #[test]
915    fn test_multiline_html_heading_incomplete() {
916        let rule = MD041FirstLineHeading::default();
917
918        // Incomplete multi-line HTML heading (missing closing tag)
919        let content = "<h1>\nSome text\n\nMore content without closing tag";
920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921        let result = rule.check(&ctx).unwrap();
922        assert_eq!(result.len(), 1);
923        assert!(result[0].message.contains("level 1 heading"));
924    }
925
926    #[test]
927    fn test_singleline_html_heading_still_works() {
928        let rule = MD041FirstLineHeading::default();
929
930        // Single-line HTML heading should still work
931        let content = "<h1>My Document</h1>\n\nContent.";
932        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933        let result = rule.check(&ctx).unwrap();
934        assert!(
935            result.is_empty(),
936            "Single-line HTML headings should still be recognized"
937        );
938    }
939
940    #[test]
941    fn test_multiline_html_heading_with_nested_tags() {
942        let rule = MD041FirstLineHeading::default();
943
944        // Multi-line HTML heading with nested tags
945        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
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            "Multi-line HTML heading with nested tags should be recognized"
951        );
952    }
953
954    #[test]
955    fn test_multiline_html_heading_various_levels() {
956        // Test multi-line headings at different levels
957        for level in 1..=6 {
958            let rule = MD041FirstLineHeading::new(level, false);
959
960            // Correct level multi-line
961            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
962            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
963            let result = rule.check(&ctx).unwrap();
964            assert!(
965                result.is_empty(),
966                "Multi-line HTML heading at level {level} should be recognized"
967            );
968
969            // Wrong level multi-line
970            let wrong_level = if level == 1 { 2 } else { 1 };
971            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
972            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
973            let result = rule.check(&ctx).unwrap();
974            assert_eq!(result.len(), 1);
975            assert!(result[0].message.contains(&format!("level {level} heading")));
976        }
977    }
978
979    #[test]
980    fn test_issue_152_nested_heading_spans_many_lines() {
981        let rule = MD041FirstLineHeading::default();
982
983        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>";
984        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985        let result = rule.check(&ctx).unwrap();
986        assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
987    }
988
989    #[test]
990    fn test_issue_152_picture_tag_heading() {
991        let rule = MD041FirstLineHeading::default();
992
993        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>";
994        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995        let result = rule.check(&ctx).unwrap();
996        assert!(
997            result.is_empty(),
998            "Picture tag inside multi-line HTML heading should be recognized"
999        );
1000    }
1001
1002    #[test]
1003    fn test_badge_images_before_heading() {
1004        let rule = MD041FirstLineHeading::default();
1005
1006        // Single badge before heading
1007        let content = "![badge](https://img.shields.io/badge/test-passing-green)\n\n# My Project";
1008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009        let result = rule.check(&ctx).unwrap();
1010        assert!(result.is_empty(), "Badge image should be skipped");
1011
1012        // Multiple badges on one line
1013        let content = "![badge1](url1) ![badge2](url2)\n\n# My Project";
1014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015        let result = rule.check(&ctx).unwrap();
1016        assert!(result.is_empty(), "Multiple badges should be skipped");
1017
1018        // Linked badge (clickable)
1019        let content = "[![badge](https://img.shields.io/badge/test-pass-green)](https://example.com)\n\n# My Project";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021        let result = rule.check(&ctx).unwrap();
1022        assert!(result.is_empty(), "Linked badge should be skipped");
1023    }
1024
1025    #[test]
1026    fn test_multiple_badge_lines_before_heading() {
1027        let rule = MD041FirstLineHeading::default();
1028
1029        // Multiple lines of badges
1030        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";
1031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032        let result = rule.check(&ctx).unwrap();
1033        assert!(result.is_empty(), "Multiple badge lines should be skipped");
1034    }
1035
1036    #[test]
1037    fn test_badges_without_heading_still_warns() {
1038        let rule = MD041FirstLineHeading::default();
1039
1040        // Badges followed by paragraph (not heading)
1041        let content = "![badge](url)\n\nThis is not a heading.";
1042        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043        let result = rule.check(&ctx).unwrap();
1044        assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1045    }
1046
1047    #[test]
1048    fn test_mixed_content_not_badge_line() {
1049        let rule = MD041FirstLineHeading::default();
1050
1051        // Image with text is not a badge line
1052        let content = "![badge](url) Some text here\n\n# Heading";
1053        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054        let result = rule.check(&ctx).unwrap();
1055        assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1056    }
1057
1058    #[test]
1059    fn test_is_badge_image_line_unit() {
1060        // Unit tests for is_badge_image_line
1061        assert!(MD041FirstLineHeading::is_badge_image_line("![badge](url)"));
1062        assert!(MD041FirstLineHeading::is_badge_image_line("[![badge](img)](link)"));
1063        assert!(MD041FirstLineHeading::is_badge_image_line("![a](b) ![c](d)"));
1064        assert!(MD041FirstLineHeading::is_badge_image_line("[![a](b)](c) [![d](e)](f)"));
1065
1066        // Not badge lines
1067        assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1068        assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1069        assert!(!MD041FirstLineHeading::is_badge_image_line("![badge](url) text"));
1070        assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1071    }
1072}