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