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 has_opening = html_tags.iter().any(|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        if !has_opening {
117            return false;
118        }
119
120        // Find matching closing tag within reasonable distance
121        const MAX_LINES_TO_SCAN: usize = 10;
122        let end_line = (first_line_idx + 1 + MAX_LINES_TO_SCAN).min(ctx.lines.len());
123
124        html_tags.iter().any(|tag| {
125            tag.line > first_line_idx + 1 // Closing must be after opening
126                && tag.line <= end_line
127                && tag.tag_name == target_tag
128                && tag.is_closing
129        })
130    }
131}
132
133impl Rule for MD041FirstLineHeading {
134    fn name(&self) -> &'static str {
135        "MD041"
136    }
137
138    fn description(&self) -> &'static str {
139        "First line in file should be a top level heading"
140    }
141
142    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
143        let mut warnings = Vec::new();
144
145        // Check if we should skip this file
146        if self.should_skip(ctx) {
147            return Ok(warnings);
148        }
149
150        // Find the first non-blank line after front matter using cached info
151        let mut first_content_line_num = None;
152        let mut skip_lines = 0;
153
154        // Check for front matter
155        if ctx.lines.first().map(|l| l.content(ctx.content).trim()) == Some("---") {
156            // Skip front matter
157            for (idx, line_info) in ctx.lines.iter().enumerate().skip(1) {
158                if line_info.content(ctx.content).trim() == "---" {
159                    skip_lines = idx + 1;
160                    break;
161                }
162            }
163        }
164
165        for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) {
166            let line_content = line_info.content(ctx.content).trim();
167            // Skip ESM blocks in MDX files (import/export statements)
168            if line_info.in_esm_block {
169                continue;
170            }
171            if !line_content.is_empty() && !Self::is_non_content_line(line_info.content(ctx.content)) {
172                first_content_line_num = Some(line_num);
173                break;
174            }
175        }
176
177        if first_content_line_num.is_none() {
178            // No non-blank lines after front matter
179            return Ok(warnings);
180        }
181
182        let first_line_idx = first_content_line_num.unwrap();
183
184        // Check if the first non-blank line is a heading of the required level
185        let first_line_info = &ctx.lines[first_line_idx];
186        let is_correct_heading = if let Some(heading) = &first_line_info.heading {
187            heading.level as usize == self.level
188        } else {
189            // Check for HTML heading (both single-line and multi-line)
190            Self::is_html_heading(ctx, first_line_idx, self.level)
191        };
192
193        if !is_correct_heading {
194            // Calculate precise character range for the entire first line
195            let first_line = first_line_idx + 1; // Convert to 1-indexed
196            let first_line_content = first_line_info.content(ctx.content);
197            let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
198
199            warnings.push(LintWarning {
200                rule_name: Some(self.name().to_string()),
201                line: start_line,
202                column: start_col,
203                end_line,
204                end_column: end_col,
205                message: format!("First line in file should be a level {} heading", self.level),
206                severity: Severity::Warning,
207                fix: None, // MD041 no longer provides auto-fix suggestions
208            });
209        }
210        Ok(warnings)
211    }
212
213    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
214        // MD041 should not auto-fix - adding content/titles is a decision that should be made by the document author
215        // This rule now only detects and warns about missing titles, but does not automatically add them
216        Ok(ctx.content.to_string())
217    }
218
219    /// Check if this rule should be skipped
220    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
221        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
222        // These files are composition/routing metadata, not standalone content.
223        // Example: A file containing only "{{#include ../../README.md}}" is a
224        // pointer to content, not content itself, and shouldn't need a heading.
225        let only_directives = !ctx.content.is_empty()
226            && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
227                let t = l.trim();
228                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
229                (t.starts_with("{{#") && t.ends_with("}}"))
230                        // HTML comments often accompany directives
231                        || (t.starts_with("<!--") && t.ends_with("-->"))
232            });
233
234        ctx.content.is_empty()
235            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
236            || only_directives
237    }
238
239    fn as_any(&self) -> &dyn std::any::Any {
240        self
241    }
242
243    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
244    where
245        Self: Sized,
246    {
247        // Load config using serde with kebab-case support
248        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
249
250        let use_front_matter = !md041_config.front_matter_title.is_empty();
251
252        Box::new(MD041FirstLineHeading::with_pattern(
253            md041_config.level.as_usize(),
254            use_front_matter,
255            md041_config.front_matter_title_pattern,
256        ))
257    }
258
259    fn default_config_section(&self) -> Option<(String, toml::Value)> {
260        Some((
261            "MD041".to_string(),
262            toml::toml! {
263                level = 1
264                front-matter-title = "title"
265                front-matter-title-pattern = ""
266            }
267            .into(),
268        ))
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::lint_context::LintContext;
276
277    #[test]
278    fn test_first_line_is_heading_correct_level() {
279        let rule = MD041FirstLineHeading::default();
280
281        // First line is a level 1 heading (should pass)
282        let content = "# My Document\n\nSome content here.";
283        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
284        let result = rule.check(&ctx).unwrap();
285        assert!(
286            result.is_empty(),
287            "Expected no warnings when first line is a level 1 heading"
288        );
289    }
290
291    #[test]
292    fn test_first_line_is_heading_wrong_level() {
293        let rule = MD041FirstLineHeading::default();
294
295        // First line is a level 2 heading (should fail with level 1 requirement)
296        let content = "## My Document\n\nSome content here.";
297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
298        let result = rule.check(&ctx).unwrap();
299        assert_eq!(result.len(), 1);
300        assert_eq!(result[0].line, 1);
301        assert!(result[0].message.contains("level 1 heading"));
302    }
303
304    #[test]
305    fn test_first_line_not_heading() {
306        let rule = MD041FirstLineHeading::default();
307
308        // First line is plain text (should fail)
309        let content = "This is not a heading\n\n# This is a heading";
310        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
311        let result = rule.check(&ctx).unwrap();
312        assert_eq!(result.len(), 1);
313        assert_eq!(result[0].line, 1);
314        assert!(result[0].message.contains("level 1 heading"));
315    }
316
317    #[test]
318    fn test_empty_lines_before_heading() {
319        let rule = MD041FirstLineHeading::default();
320
321        // Empty lines before first heading (should pass - rule skips empty lines)
322        let content = "\n\n# My Document\n\nSome content.";
323        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
324        let result = rule.check(&ctx).unwrap();
325        assert!(
326            result.is_empty(),
327            "Expected no warnings when empty lines precede a valid heading"
328        );
329
330        // Empty lines before non-heading content (should fail)
331        let content = "\n\nNot a heading\n\nSome content.";
332        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
333        let result = rule.check(&ctx).unwrap();
334        assert_eq!(result.len(), 1);
335        assert_eq!(result[0].line, 3); // First non-empty line
336        assert!(result[0].message.contains("level 1 heading"));
337    }
338
339    #[test]
340    fn test_front_matter_with_title() {
341        let rule = MD041FirstLineHeading::new(1, true);
342
343        // Front matter with title field (should pass)
344        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
346        let result = rule.check(&ctx).unwrap();
347        assert!(
348            result.is_empty(),
349            "Expected no warnings when front matter has title field"
350        );
351    }
352
353    #[test]
354    fn test_front_matter_without_title() {
355        let rule = MD041FirstLineHeading::new(1, true);
356
357        // Front matter without title field (should fail)
358        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
360        let result = rule.check(&ctx).unwrap();
361        assert_eq!(result.len(), 1);
362        assert_eq!(result[0].line, 6); // First content line after front matter
363    }
364
365    #[test]
366    fn test_front_matter_disabled() {
367        let rule = MD041FirstLineHeading::new(1, false);
368
369        // Front matter with title field but front_matter_title is false (should fail)
370        let content = "---\ntitle: My Document\n---\n\nSome content here.";
371        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
372        let result = rule.check(&ctx).unwrap();
373        assert_eq!(result.len(), 1);
374        assert_eq!(result[0].line, 5); // First content line after front matter
375    }
376
377    #[test]
378    fn test_html_comments_before_heading() {
379        let rule = MD041FirstLineHeading::default();
380
381        // HTML comment before heading (should fail)
382        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
384        let result = rule.check(&ctx).unwrap();
385        assert_eq!(result.len(), 1);
386        assert_eq!(result[0].line, 1); // HTML comment is the first line
387    }
388
389    #[test]
390    fn test_different_heading_levels() {
391        // Test with level 2 requirement
392        let rule = MD041FirstLineHeading::new(2, false);
393
394        let content = "## Second Level Heading\n\nContent.";
395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
396        let result = rule.check(&ctx).unwrap();
397        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
398
399        // Wrong level
400        let content = "# First Level Heading\n\nContent.";
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
402        let result = rule.check(&ctx).unwrap();
403        assert_eq!(result.len(), 1);
404        assert!(result[0].message.contains("level 2 heading"));
405    }
406
407    #[test]
408    fn test_setext_headings() {
409        let rule = MD041FirstLineHeading::default();
410
411        // Setext style level 1 heading (should pass)
412        let content = "My Document\n===========\n\nContent.";
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414        let result = rule.check(&ctx).unwrap();
415        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
416
417        // Setext style level 2 heading (should fail with level 1 requirement)
418        let content = "My Document\n-----------\n\nContent.";
419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
420        let result = rule.check(&ctx).unwrap();
421        assert_eq!(result.len(), 1);
422        assert!(result[0].message.contains("level 1 heading"));
423    }
424
425    #[test]
426    fn test_empty_document() {
427        let rule = MD041FirstLineHeading::default();
428
429        // Empty document (should pass - no warnings)
430        let content = "";
431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
432        let result = rule.check(&ctx).unwrap();
433        assert!(result.is_empty(), "Expected no warnings for empty document");
434    }
435
436    #[test]
437    fn test_whitespace_only_document() {
438        let rule = MD041FirstLineHeading::default();
439
440        // Document with only whitespace (should pass - no warnings)
441        let content = "   \n\n   \t\n";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
443        let result = rule.check(&ctx).unwrap();
444        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
445    }
446
447    #[test]
448    fn test_front_matter_then_whitespace() {
449        let rule = MD041FirstLineHeading::default();
450
451        // Front matter followed by only whitespace (should pass - no warnings)
452        let content = "---\ntitle: Test\n---\n\n   \n\n";
453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
454        let result = rule.check(&ctx).unwrap();
455        assert!(
456            result.is_empty(),
457            "Expected no warnings when no content after front matter"
458        );
459    }
460
461    #[test]
462    fn test_multiple_front_matter_types() {
463        let rule = MD041FirstLineHeading::new(1, true);
464
465        // TOML front matter with title (should fail - rule only checks for "title:" pattern)
466        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468        let result = rule.check(&ctx).unwrap();
469        assert_eq!(result.len(), 1);
470        assert!(result[0].message.contains("level 1 heading"));
471
472        // JSON front matter with title (should fail - doesn't have "title:" pattern, has "\"title\":")
473        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
475        let result = rule.check(&ctx).unwrap();
476        assert_eq!(result.len(), 1);
477        assert!(result[0].message.contains("level 1 heading"));
478
479        // YAML front matter with title field (standard case)
480        let content = "---\ntitle: My Document\n---\n\nContent.";
481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
482        let result = rule.check(&ctx).unwrap();
483        assert!(
484            result.is_empty(),
485            "Expected no warnings for YAML front matter with title"
486        );
487
488        // Test mixed format edge case - YAML-style in TOML
489        let content = "+++\ntitle: My Document\n+++\n\nContent.";
490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
491        let result = rule.check(&ctx).unwrap();
492        assert!(result.is_empty(), "Expected no warnings when title: pattern is found");
493    }
494
495    #[test]
496    fn test_malformed_front_matter() {
497        let rule = MD041FirstLineHeading::new(1, true);
498
499        // Malformed front matter with title
500        let content = "- --\ntitle: My Document\n- --\n\nContent.";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502        let result = rule.check(&ctx).unwrap();
503        assert!(
504            result.is_empty(),
505            "Expected no warnings for malformed front matter with title"
506        );
507    }
508
509    #[test]
510    fn test_front_matter_with_heading() {
511        let rule = MD041FirstLineHeading::default();
512
513        // Front matter without title field followed by correct heading
514        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
516        let result = rule.check(&ctx).unwrap();
517        assert!(
518            result.is_empty(),
519            "Expected no warnings when first line after front matter is correct heading"
520        );
521    }
522
523    #[test]
524    fn test_no_fix_suggestion() {
525        let rule = MD041FirstLineHeading::default();
526
527        // Check that NO fix suggestion is provided (MD041 is now detection-only)
528        let content = "Not a heading\n\nContent.";
529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530        let result = rule.check(&ctx).unwrap();
531        assert_eq!(result.len(), 1);
532        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
533    }
534
535    #[test]
536    fn test_complex_document_structure() {
537        let rule = MD041FirstLineHeading::default();
538
539        // Complex document with various elements
540        let content =
541            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
543        let result = rule.check(&ctx).unwrap();
544        assert_eq!(result.len(), 1);
545        assert_eq!(result[0].line, 5); // The comment line
546    }
547
548    #[test]
549    fn test_heading_with_special_characters() {
550        let rule = MD041FirstLineHeading::default();
551
552        // Heading with special characters and formatting
553        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
555        let result = rule.check(&ctx).unwrap();
556        assert!(
557            result.is_empty(),
558            "Expected no warnings for heading with inline formatting"
559        );
560    }
561
562    #[test]
563    fn test_level_configuration() {
564        // Test various level configurations
565        for level in 1..=6 {
566            let rule = MD041FirstLineHeading::new(level, false);
567
568            // Correct level
569            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
570            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
571            let result = rule.check(&ctx).unwrap();
572            assert!(
573                result.is_empty(),
574                "Expected no warnings for correct level {level} heading"
575            );
576
577            // Wrong level
578            let wrong_level = if level == 1 { 2 } else { 1 };
579            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
580            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
581            let result = rule.check(&ctx).unwrap();
582            assert_eq!(result.len(), 1);
583            assert!(result[0].message.contains(&format!("level {level} heading")));
584        }
585    }
586
587    #[test]
588    fn test_issue_152_multiline_html_heading() {
589        let rule = MD041FirstLineHeading::default();
590
591        // Multi-line HTML h1 heading (should pass - issue #152)
592        let content = "<h1>\nSome text\n</h1>";
593        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
594        let result = rule.check(&ctx).unwrap();
595        assert!(
596            result.is_empty(),
597            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
598        );
599    }
600
601    #[test]
602    fn test_multiline_html_heading_with_attributes() {
603        let rule = MD041FirstLineHeading::default();
604
605        // Multi-line HTML heading with attributes
606        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
608        let result = rule.check(&ctx).unwrap();
609        assert!(
610            result.is_empty(),
611            "Multi-line HTML heading with attributes should be recognized"
612        );
613    }
614
615    #[test]
616    fn test_multiline_html_heading_wrong_level() {
617        let rule = MD041FirstLineHeading::default();
618
619        // Multi-line HTML h2 heading (should fail with level 1 requirement)
620        let content = "<h2>\nSome text\n</h2>";
621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622        let result = rule.check(&ctx).unwrap();
623        assert_eq!(result.len(), 1);
624        assert!(result[0].message.contains("level 1 heading"));
625    }
626
627    #[test]
628    fn test_multiline_html_heading_with_content_after() {
629        let rule = MD041FirstLineHeading::default();
630
631        // Multi-line HTML heading followed by content
632        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634        let result = rule.check(&ctx).unwrap();
635        assert!(
636            result.is_empty(),
637            "Multi-line HTML heading followed by content should be valid"
638        );
639    }
640
641    #[test]
642    fn test_multiline_html_heading_incomplete() {
643        let rule = MD041FirstLineHeading::default();
644
645        // Incomplete multi-line HTML heading (missing closing tag)
646        let content = "<h1>\nSome text\n\nMore content without closing tag";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648        let result = rule.check(&ctx).unwrap();
649        assert_eq!(result.len(), 1);
650        assert!(result[0].message.contains("level 1 heading"));
651    }
652
653    #[test]
654    fn test_singleline_html_heading_still_works() {
655        let rule = MD041FirstLineHeading::default();
656
657        // Single-line HTML heading should still work
658        let content = "<h1>My Document</h1>\n\nContent.";
659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
660        let result = rule.check(&ctx).unwrap();
661        assert!(
662            result.is_empty(),
663            "Single-line HTML headings should still be recognized"
664        );
665    }
666
667    #[test]
668    fn test_multiline_html_heading_with_nested_tags() {
669        let rule = MD041FirstLineHeading::default();
670
671        // Multi-line HTML heading with nested tags
672        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
674        let result = rule.check(&ctx).unwrap();
675        assert!(
676            result.is_empty(),
677            "Multi-line HTML heading with nested tags should be recognized"
678        );
679    }
680
681    #[test]
682    fn test_multiline_html_heading_various_levels() {
683        // Test multi-line headings at different levels
684        for level in 1..=6 {
685            let rule = MD041FirstLineHeading::new(level, false);
686
687            // Correct level multi-line
688            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
689            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
690            let result = rule.check(&ctx).unwrap();
691            assert!(
692                result.is_empty(),
693                "Multi-line HTML heading at level {level} should be recognized"
694            );
695
696            // Wrong level multi-line
697            let wrong_level = if level == 1 { 2 } else { 1 };
698            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
699            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
700            let result = rule.check(&ctx).unwrap();
701            assert_eq!(result.len(), 1);
702            assert!(result[0].message.contains(&format!("level {level} heading")));
703        }
704    }
705}