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        false
92    }
93
94    /// Check if a line is an HTML heading using the centralized HTML parser
95    fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
96        // Check for single-line HTML heading using regex (fast path)
97        let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
98        if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
99            && let Some(h_level) = captures.get(1)
100            && h_level.as_str().parse::<usize>().unwrap_or(0) == level
101        {
102            return true;
103        }
104
105        // Use centralized HTML parser for multi-line headings
106        let html_tags = ctx.html_tags();
107        let target_tag = format!("h{level}");
108
109        // Find opening tag on first line
110        let opening_index = html_tags.iter().position(|tag| {
111            tag.line == first_line_idx + 1 // HtmlTag uses 1-indexed lines
112                && tag.tag_name == target_tag
113                && !tag.is_closing
114        });
115
116        let Some(open_idx) = opening_index else {
117            return false;
118        };
119
120        // Walk HTML tags to find the corresponding closing tag, allowing arbitrary nesting depth.
121        // This avoids brittle line-count heuristics and handles long headings with nested content.
122        let mut depth = 1usize;
123        for tag in html_tags.iter().skip(open_idx + 1) {
124            // Ignore tags that appear before the first heading line (possible when multiple tags share a line)
125            if tag.line <= first_line_idx + 1 {
126                continue;
127            }
128
129            if tag.tag_name == target_tag {
130                if tag.is_closing {
131                    depth -= 1;
132                    if depth == 0 {
133                        return true;
134                    }
135                } else if !tag.is_self_closing {
136                    depth += 1;
137                }
138            }
139        }
140
141        false
142    }
143}
144
145impl Rule for MD041FirstLineHeading {
146    fn name(&self) -> &'static str {
147        "MD041"
148    }
149
150    fn description(&self) -> &'static str {
151        "First line in file should be a top level heading"
152    }
153
154    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
155        let mut warnings = Vec::new();
156
157        // Check if we should skip this file
158        if self.should_skip(ctx) {
159            return Ok(warnings);
160        }
161
162        // Find the first non-blank line after front matter using cached info
163        let mut first_content_line_num = None;
164        let mut skip_lines = 0;
165
166        // Check for front matter
167        if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
168            // Skip front matter
169            for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
170                if line_info.content(ctx.content).trim() == "---" {
171                    skip_lines = idx + 1;
172                    break;
173                }
174            }
175        }
176
177        for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
178            let line_content = line_info.content(ctx.content).trim();
179            // Skip ESM blocks in MDX files (import/export statements)
180            if line_info.in_esm_block {
181                continue;
182            }
183            // Skip HTML comments - they are non-visible and should not affect MD041
184            if line_info.in_html_comment {
185                continue;
186            }
187            if !line_content.is_empty() && !Self::is_non_content_line(line_info.content(ctx.content)) {
188                first_content_line_num = Some(line_num);
189                break;
190            }
191        }
192
193        if first_content_line_num.is_none() {
194            // No non-blank lines after front matter
195            return Ok(warnings);
196        }
197
198        let first_line_idx = first_content_line_num.unwrap();
199
200        // Check if the first non-blank line is a heading of the required level
201        let first_line_info = &ctx.lines[first_line_idx];
202        let is_correct_heading = if let Some(heading) = &first_line_info.heading {
203            heading.level as usize == self.level
204        } else {
205            // Check for HTML heading (both single-line and multi-line)
206            Self::is_html_heading(ctx, first_line_idx, self.level)
207        };
208
209        if !is_correct_heading {
210            // Calculate precise character range for the entire first line
211            let first_line = first_line_idx + 1; // Convert to 1-indexed
212            let first_line_content = first_line_info.content(ctx.content);
213            let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
214
215            warnings.push(LintWarning {
216                rule_name: Some(self.name().to_string()),
217                line: start_line,
218                column: start_col,
219                end_line,
220                end_column: end_col,
221                message: format!("First line in file should be a level {} heading", self.level),
222                severity: Severity::Warning,
223                fix: None, // MD041 no longer provides auto-fix suggestions
224            });
225        }
226        Ok(warnings)
227    }
228
229    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
230        // MD041 should not auto-fix - adding content/titles is a decision that should be made by the document author
231        // This rule now only detects and warns about missing titles, but does not automatically add them
232        Ok(ctx.content.to_string())
233    }
234
235    /// Check if this rule should be skipped
236    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
237        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
238        // These files are composition/routing metadata, not standalone content.
239        // Example: A file containing only "{{#include ../../README.md}}" is a
240        // pointer to content, not content itself, and shouldn't need a heading.
241        let only_directives = !ctx.content.is_empty()
242            && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
243                let t = l.trim();
244                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
245                (t.starts_with("{{#") && t.ends_with("}}"))
246                        // HTML comments often accompany directives
247                        || (t.starts_with("<!--") && t.ends_with("-->"))
248            });
249
250        ctx.content.is_empty()
251            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
252            || only_directives
253    }
254
255    fn as_any(&self) -> &dyn std::any::Any {
256        self
257    }
258
259    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
260    where
261        Self: Sized,
262    {
263        // Load config using serde with kebab-case support
264        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
265
266        let use_front_matter = !md041_config.front_matter_title.is_empty();
267
268        Box::new(MD041FirstLineHeading::with_pattern(
269            md041_config.level.as_usize(),
270            use_front_matter,
271            md041_config.front_matter_title_pattern,
272        ))
273    }
274
275    fn default_config_section(&self) -> Option<(String, toml::Value)> {
276        Some((
277            "MD041".to_string(),
278            toml::toml! {
279                level = 1
280                front-matter-title = "title"
281                front-matter-title-pattern = ""
282            }
283            .into(),
284        ))
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::lint_context::LintContext;
292
293    #[test]
294    fn test_first_line_is_heading_correct_level() {
295        let rule = MD041FirstLineHeading::default();
296
297        // First line is a level 1 heading (should pass)
298        let content = "# My Document\n\nSome content here.";
299        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
300        let result = rule.check(&ctx).unwrap();
301        assert!(
302            result.is_empty(),
303            "Expected no warnings when first line is a level 1 heading"
304        );
305    }
306
307    #[test]
308    fn test_first_line_is_heading_wrong_level() {
309        let rule = MD041FirstLineHeading::default();
310
311        // First line is a level 2 heading (should fail with level 1 requirement)
312        let content = "## My Document\n\nSome content here.";
313        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
314        let result = rule.check(&ctx).unwrap();
315        assert_eq!(result.len(), 1);
316        assert_eq!(result[0].line, 1);
317        assert!(result[0].message.contains("level 1 heading"));
318    }
319
320    #[test]
321    fn test_first_line_not_heading() {
322        let rule = MD041FirstLineHeading::default();
323
324        // First line is plain text (should fail)
325        let content = "This is not a heading\n\n# This is a heading";
326        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
327        let result = rule.check(&ctx).unwrap();
328        assert_eq!(result.len(), 1);
329        assert_eq!(result[0].line, 1);
330        assert!(result[0].message.contains("level 1 heading"));
331    }
332
333    #[test]
334    fn test_empty_lines_before_heading() {
335        let rule = MD041FirstLineHeading::default();
336
337        // Empty lines before first heading (should pass - rule skips empty lines)
338        let content = "\n\n# My Document\n\nSome content.";
339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
340        let result = rule.check(&ctx).unwrap();
341        assert!(
342            result.is_empty(),
343            "Expected no warnings when empty lines precede a valid heading"
344        );
345
346        // Empty lines before non-heading content (should fail)
347        let content = "\n\nNot a heading\n\nSome content.";
348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349        let result = rule.check(&ctx).unwrap();
350        assert_eq!(result.len(), 1);
351        assert_eq!(result[0].line, 3); // First non-empty line
352        assert!(result[0].message.contains("level 1 heading"));
353    }
354
355    #[test]
356    fn test_front_matter_with_title() {
357        let rule = MD041FirstLineHeading::new(1, true);
358
359        // Front matter with title field (should pass)
360        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
362        let result = rule.check(&ctx).unwrap();
363        assert!(
364            result.is_empty(),
365            "Expected no warnings when front matter has title field"
366        );
367    }
368
369    #[test]
370    fn test_front_matter_without_title() {
371        let rule = MD041FirstLineHeading::new(1, true);
372
373        // Front matter without title field (should fail)
374        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376        let result = rule.check(&ctx).unwrap();
377        assert_eq!(result.len(), 1);
378        assert_eq!(result[0].line, 6); // First content line after front matter
379    }
380
381    #[test]
382    fn test_front_matter_disabled() {
383        let rule = MD041FirstLineHeading::new(1, false);
384
385        // Front matter with title field but front_matter_title is false (should fail)
386        let content = "---\ntitle: My Document\n---\n\nSome content here.";
387        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
388        let result = rule.check(&ctx).unwrap();
389        assert_eq!(result.len(), 1);
390        assert_eq!(result[0].line, 5); // First content line after front matter
391    }
392
393    #[test]
394    fn test_html_comments_before_heading() {
395        let rule = MD041FirstLineHeading::default();
396
397        // HTML comment before heading (should pass - comments are skipped, issue #155)
398        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
400        let result = rule.check(&ctx).unwrap();
401        assert!(
402            result.is_empty(),
403            "HTML comments should be skipped when checking for first heading"
404        );
405    }
406
407    #[test]
408    fn test_multiline_html_comment_before_heading() {
409        let rule = MD041FirstLineHeading::default();
410
411        // Multi-line HTML comment before heading (should pass - issue #155)
412        let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414        let result = rule.check(&ctx).unwrap();
415        assert!(
416            result.is_empty(),
417            "Multi-line HTML comments should be skipped when checking for first heading"
418        );
419    }
420
421    #[test]
422    fn test_html_comment_with_blank_lines_before_heading() {
423        let rule = MD041FirstLineHeading::default();
424
425        // HTML comment with blank lines before heading (should pass - issue #155)
426        let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428        let result = rule.check(&ctx).unwrap();
429        assert!(
430            result.is_empty(),
431            "HTML comments with blank lines should be skipped when checking for first heading"
432        );
433    }
434
435    #[test]
436    fn test_html_comment_before_html_heading() {
437        let rule = MD041FirstLineHeading::default();
438
439        // HTML comment before HTML heading (should pass - issue #155)
440        let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
442        let result = rule.check(&ctx).unwrap();
443        assert!(
444            result.is_empty(),
445            "HTML comments should be skipped before HTML headings"
446        );
447    }
448
449    #[test]
450    fn test_document_with_only_html_comments() {
451        let rule = MD041FirstLineHeading::default();
452
453        // Document with only HTML comments (should pass - no warnings for comment-only files)
454        let content = "<!-- This is a comment -->\n<!-- Another comment -->";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456        let result = rule.check(&ctx).unwrap();
457        assert!(
458            result.is_empty(),
459            "Documents with only HTML comments should not trigger MD041"
460        );
461    }
462
463    #[test]
464    fn test_html_comment_followed_by_non_heading() {
465        let rule = MD041FirstLineHeading::default();
466
467        // HTML comment followed by non-heading content (should still fail - issue #155)
468        let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
470        let result = rule.check(&ctx).unwrap();
471        assert_eq!(
472            result.len(),
473            1,
474            "HTML comment followed by non-heading should still trigger MD041"
475        );
476        assert_eq!(
477            result[0].line, 2,
478            "Warning should be on the first non-comment, non-heading line"
479        );
480    }
481
482    #[test]
483    fn test_multiple_html_comments_before_heading() {
484        let rule = MD041FirstLineHeading::default();
485
486        // Multiple HTML comments before heading (should pass - issue #155)
487        let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
489        let result = rule.check(&ctx).unwrap();
490        assert!(
491            result.is_empty(),
492            "Multiple HTML comments should all be skipped before heading"
493        );
494    }
495
496    #[test]
497    fn test_html_comment_with_wrong_level_heading() {
498        let rule = MD041FirstLineHeading::default();
499
500        // HTML comment followed by wrong-level heading (should fail - issue #155)
501        let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
503        let result = rule.check(&ctx).unwrap();
504        assert_eq!(
505            result.len(),
506            1,
507            "HTML comment followed by wrong-level heading should still trigger MD041"
508        );
509        assert!(
510            result[0].message.contains("level 1 heading"),
511            "Should require level 1 heading"
512        );
513    }
514
515    #[test]
516    fn test_html_comment_mixed_with_reference_definitions() {
517        let rule = MD041FirstLineHeading::default();
518
519        // HTML comment mixed with reference definitions before heading (should pass - issue #155)
520        let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
522        let result = rule.check(&ctx).unwrap();
523        assert!(
524            result.is_empty(),
525            "HTML comments and reference definitions should both be skipped before heading"
526        );
527    }
528
529    #[test]
530    fn test_html_comment_after_front_matter() {
531        let rule = MD041FirstLineHeading::default();
532
533        // HTML comment after front matter, before heading (should pass - issue #155)
534        let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
535        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
536        let result = rule.check(&ctx).unwrap();
537        assert!(
538            result.is_empty(),
539            "HTML comments after front matter should be skipped before heading"
540        );
541    }
542
543    #[test]
544    fn test_html_comment_not_at_start_should_not_affect_rule() {
545        let rule = MD041FirstLineHeading::default();
546
547        // HTML comment in middle of document should not affect MD041 check
548        let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
549        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
550        let result = rule.check(&ctx).unwrap();
551        assert!(
552            result.is_empty(),
553            "HTML comments in middle of document should not affect MD041 (only first content matters)"
554        );
555    }
556
557    #[test]
558    fn test_multiline_html_comment_followed_by_non_heading() {
559        let rule = MD041FirstLineHeading::default();
560
561        // Multi-line HTML comment followed by non-heading (should still fail - issue #155)
562        let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
564        let result = rule.check(&ctx).unwrap();
565        assert_eq!(
566            result.len(),
567            1,
568            "Multi-line HTML comment followed by non-heading should still trigger MD041"
569        );
570        assert_eq!(
571            result[0].line, 5,
572            "Warning should be on the first non-comment, non-heading line"
573        );
574    }
575
576    #[test]
577    fn test_different_heading_levels() {
578        // Test with level 2 requirement
579        let rule = MD041FirstLineHeading::new(2, false);
580
581        let content = "## Second Level Heading\n\nContent.";
582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583        let result = rule.check(&ctx).unwrap();
584        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
585
586        // Wrong level
587        let content = "# First Level Heading\n\nContent.";
588        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589        let result = rule.check(&ctx).unwrap();
590        assert_eq!(result.len(), 1);
591        assert!(result[0].message.contains("level 2 heading"));
592    }
593
594    #[test]
595    fn test_setext_headings() {
596        let rule = MD041FirstLineHeading::default();
597
598        // Setext style level 1 heading (should pass)
599        let content = "My Document\n===========\n\nContent.";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601        let result = rule.check(&ctx).unwrap();
602        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
603
604        // Setext style level 2 heading (should fail with level 1 requirement)
605        let content = "My Document\n-----------\n\nContent.";
606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607        let result = rule.check(&ctx).unwrap();
608        assert_eq!(result.len(), 1);
609        assert!(result[0].message.contains("level 1 heading"));
610    }
611
612    #[test]
613    fn test_empty_document() {
614        let rule = MD041FirstLineHeading::default();
615
616        // Empty document (should pass - no warnings)
617        let content = "";
618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619        let result = rule.check(&ctx).unwrap();
620        assert!(result.is_empty(), "Expected no warnings for empty document");
621    }
622
623    #[test]
624    fn test_whitespace_only_document() {
625        let rule = MD041FirstLineHeading::default();
626
627        // Document with only whitespace (should pass - no warnings)
628        let content = "   \n\n   \t\n";
629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
630        let result = rule.check(&ctx).unwrap();
631        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
632    }
633
634    #[test]
635    fn test_front_matter_then_whitespace() {
636        let rule = MD041FirstLineHeading::default();
637
638        // Front matter followed by only whitespace (should pass - no warnings)
639        let content = "---\ntitle: Test\n---\n\n   \n\n";
640        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
641        let result = rule.check(&ctx).unwrap();
642        assert!(
643            result.is_empty(),
644            "Expected no warnings when no content after front matter"
645        );
646    }
647
648    #[test]
649    fn test_multiple_front_matter_types() {
650        let rule = MD041FirstLineHeading::new(1, true);
651
652        // TOML front matter with title (should fail - rule only checks for "title:" pattern)
653        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
655        let result = rule.check(&ctx).unwrap();
656        assert_eq!(result.len(), 1);
657        assert!(result[0].message.contains("level 1 heading"));
658
659        // JSON front matter with title (should fail - doesn't have "title:" pattern, has "\"title\":")
660        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
661        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
662        let result = rule.check(&ctx).unwrap();
663        assert_eq!(result.len(), 1);
664        assert!(result[0].message.contains("level 1 heading"));
665
666        // YAML front matter with title field (standard case)
667        let content = "---\ntitle: My Document\n---\n\nContent.";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
669        let result = rule.check(&ctx).unwrap();
670        assert!(
671            result.is_empty(),
672            "Expected no warnings for YAML front matter with title"
673        );
674
675        // Test mixed format edge case - YAML-style in TOML
676        let content = "+++\ntitle: My Document\n+++\n\nContent.";
677        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678        let result = rule.check(&ctx).unwrap();
679        assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
680    }
681
682    #[test]
683    fn test_malformed_front_matter() {
684        let rule = MD041FirstLineHeading::new(1, true);
685
686        // Malformed front matter with title
687        let content = "- --\ntitle: My Document\n- --\n\nContent.";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
689        let result = rule.check(&ctx).unwrap();
690        assert!(
691            result.is_empty(),
692            "Expected no warnings for malformed front matter with title"
693        );
694    }
695
696    #[test]
697    fn test_front_matter_with_heading() {
698        let rule = MD041FirstLineHeading::default();
699
700        // Front matter without title field followed by correct heading
701        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
703        let result = rule.check(&ctx).unwrap();
704        assert!(
705            result.is_empty(),
706            "Expected no warnings when first line after front matter is correct heading"
707        );
708    }
709
710    #[test]
711    fn test_no_fix_suggestion() {
712        let rule = MD041FirstLineHeading::default();
713
714        // Check that NO fix suggestion is provided (MD041 is now detection-only)
715        let content = "Not a heading\n\nContent.";
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
717        let result = rule.check(&ctx).unwrap();
718        assert_eq!(result.len(), 1);
719        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
720    }
721
722    #[test]
723    fn test_complex_document_structure() {
724        let rule = MD041FirstLineHeading::default();
725
726        // Complex document with various elements - HTML comment should be skipped (issue #155)
727        let content =
728            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
730        let result = rule.check(&ctx).unwrap();
731        assert!(
732            result.is_empty(),
733            "HTML comments should be skipped, so first heading after comment should be valid"
734        );
735    }
736
737    #[test]
738    fn test_heading_with_special_characters() {
739        let rule = MD041FirstLineHeading::default();
740
741        // Heading with special characters and formatting
742        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
744        let result = rule.check(&ctx).unwrap();
745        assert!(
746            result.is_empty(),
747            "Expected no warnings for heading with inline formatting"
748        );
749    }
750
751    #[test]
752    fn test_level_configuration() {
753        // Test various level configurations
754        for level in 1..=6 {
755            let rule = MD041FirstLineHeading::new(level, false);
756
757            // Correct level
758            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
759            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
760            let result = rule.check(&ctx).unwrap();
761            assert!(
762                result.is_empty(),
763                "Expected no warnings for correct level {level} heading"
764            );
765
766            // Wrong level
767            let wrong_level = if level == 1 { 2 } else { 1 };
768            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
769            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
770            let result = rule.check(&ctx).unwrap();
771            assert_eq!(result.len(), 1);
772            assert!(result[0].message.contains(&format!("level {level} heading")));
773        }
774    }
775
776    #[test]
777    fn test_issue_152_multiline_html_heading() {
778        let rule = MD041FirstLineHeading::default();
779
780        // Multi-line HTML h1 heading (should pass - issue #152)
781        let content = "<h1>\nSome text\n</h1>";
782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
783        let result = rule.check(&ctx).unwrap();
784        assert!(
785            result.is_empty(),
786            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
787        );
788    }
789
790    #[test]
791    fn test_multiline_html_heading_with_attributes() {
792        let rule = MD041FirstLineHeading::default();
793
794        // Multi-line HTML heading with attributes
795        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
796        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
797        let result = rule.check(&ctx).unwrap();
798        assert!(
799            result.is_empty(),
800            "Multi-line HTML heading with attributes should be recognized"
801        );
802    }
803
804    #[test]
805    fn test_multiline_html_heading_wrong_level() {
806        let rule = MD041FirstLineHeading::default();
807
808        // Multi-line HTML h2 heading (should fail with level 1 requirement)
809        let content = "<h2>\nSome text\n</h2>";
810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
811        let result = rule.check(&ctx).unwrap();
812        assert_eq!(result.len(), 1);
813        assert!(result[0].message.contains("level 1 heading"));
814    }
815
816    #[test]
817    fn test_multiline_html_heading_with_content_after() {
818        let rule = MD041FirstLineHeading::default();
819
820        // Multi-line HTML heading followed by content
821        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
822        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
823        let result = rule.check(&ctx).unwrap();
824        assert!(
825            result.is_empty(),
826            "Multi-line HTML heading followed by content should be valid"
827        );
828    }
829
830    #[test]
831    fn test_multiline_html_heading_incomplete() {
832        let rule = MD041FirstLineHeading::default();
833
834        // Incomplete multi-line HTML heading (missing closing tag)
835        let content = "<h1>\nSome text\n\nMore content without closing tag";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
837        let result = rule.check(&ctx).unwrap();
838        assert_eq!(result.len(), 1);
839        assert!(result[0].message.contains("level 1 heading"));
840    }
841
842    #[test]
843    fn test_singleline_html_heading_still_works() {
844        let rule = MD041FirstLineHeading::default();
845
846        // Single-line HTML heading should still work
847        let content = "<h1>My Document</h1>\n\nContent.";
848        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
849        let result = rule.check(&ctx).unwrap();
850        assert!(
851            result.is_empty(),
852            "Single-line HTML headings should still be recognized"
853        );
854    }
855
856    #[test]
857    fn test_multiline_html_heading_with_nested_tags() {
858        let rule = MD041FirstLineHeading::default();
859
860        // Multi-line HTML heading with nested tags
861        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
862        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
863        let result = rule.check(&ctx).unwrap();
864        assert!(
865            result.is_empty(),
866            "Multi-line HTML heading with nested tags should be recognized"
867        );
868    }
869
870    #[test]
871    fn test_multiline_html_heading_various_levels() {
872        // Test multi-line headings at different levels
873        for level in 1..=6 {
874            let rule = MD041FirstLineHeading::new(level, false);
875
876            // Correct level multi-line
877            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
878            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
879            let result = rule.check(&ctx).unwrap();
880            assert!(
881                result.is_empty(),
882                "Multi-line HTML heading at level {level} should be recognized"
883            );
884
885            // Wrong level multi-line
886            let wrong_level = if level == 1 { 2 } else { 1 };
887            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
888            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
889            let result = rule.check(&ctx).unwrap();
890            assert_eq!(result.len(), 1);
891            assert!(result[0].message.contains(&format!("level {level} heading")));
892        }
893    }
894
895    #[test]
896    fn test_issue_152_nested_heading_spans_many_lines() {
897        let rule = MD041FirstLineHeading::default();
898
899        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>";
900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
901        let result = rule.check(&ctx).unwrap();
902        assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
903    }
904
905    #[test]
906    fn test_issue_152_picture_tag_heading() {
907        let rule = MD041FirstLineHeading::default();
908
909        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>";
910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911        let result = rule.check(&ctx).unwrap();
912        assert!(
913            result.is_empty(),
914            "Picture tag inside multi-line HTML heading should be recognized"
915        );
916    }
917}