rumdl_lib/rules/
md025_single_title.rs

1/// Rule MD025: Document must have a single top-level heading
2///
3/// See [docs/md025.md](../../docs/md025.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::types::HeadingLevel;
6use crate::utils::range_utils::calculate_match_range;
7use crate::utils::regex_cache::{
8    HR_ASTERISK, HR_DASH, HR_SPACED_ASTERISK, HR_SPACED_DASH, HR_SPACED_UNDERSCORE, HR_UNDERSCORE,
9};
10use toml;
11
12mod md025_config;
13use md025_config::MD025Config;
14
15#[derive(Clone, Default)]
16pub struct MD025SingleTitle {
17    config: MD025Config,
18}
19
20impl MD025SingleTitle {
21    pub fn new(level: usize, front_matter_title: &str) -> Self {
22        Self {
23            config: MD025Config {
24                level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
25                front_matter_title: front_matter_title.to_string(),
26                allow_document_sections: true,
27                allow_with_separators: true,
28            },
29        }
30    }
31
32    pub fn strict() -> Self {
33        Self {
34            config: MD025Config {
35                level: HeadingLevel::new(1).unwrap(),
36                front_matter_title: "title".to_string(),
37                allow_document_sections: false,
38                allow_with_separators: false,
39            },
40        }
41    }
42
43    pub fn from_config_struct(config: MD025Config) -> Self {
44        Self { config }
45    }
46
47    /// Check if a heading text suggests it's a legitimate document section
48    fn is_document_section_heading(&self, heading_text: &str) -> bool {
49        if !self.config.allow_document_sections {
50            return false;
51        }
52
53        let lower_text = heading_text.to_lowercase();
54
55        // Common section names that are legitimate as separate H1s
56        let section_indicators = [
57            "appendix",
58            "appendices",
59            "reference",
60            "references",
61            "bibliography",
62            "index",
63            "indices",
64            "glossary",
65            "glossaries",
66            "conclusion",
67            "conclusions",
68            "summary",
69            "executive summary",
70            "acknowledgment",
71            "acknowledgments",
72            "acknowledgement",
73            "acknowledgements",
74            "about",
75            "contact",
76            "license",
77            "legal",
78            "changelog",
79            "change log",
80            "history",
81            "faq",
82            "frequently asked questions",
83            "troubleshooting",
84            "support",
85            "installation",
86            "setup",
87            "getting started",
88            "api reference",
89            "api documentation",
90            "examples",
91            "tutorials",
92            "guides",
93        ];
94
95        // Check if the heading starts with these patterns
96        section_indicators.iter().any(|&indicator| {
97            lower_text.starts_with(indicator) ||
98            lower_text.starts_with(&format!("{indicator}:")) ||
99            lower_text.contains(&format!(" {indicator}")) ||
100            // Handle appendix numbering like "Appendix A", "Appendix 1"
101            (indicator == "appendix" && (
102                lower_text.matches("appendix").count() == 1 &&
103                (lower_text.contains(" a") || lower_text.contains(" b") ||
104                 lower_text.contains(" 1") || lower_text.contains(" 2") ||
105                 lower_text.contains(" i") || lower_text.contains(" ii"))
106            ))
107        })
108    }
109
110    /// Check if a line is a horizontal rule
111    fn is_horizontal_rule(line: &str) -> bool {
112        let trimmed = line.trim();
113        HR_DASH.is_match(trimmed)
114            || HR_ASTERISK.is_match(trimmed)
115            || HR_UNDERSCORE.is_match(trimmed)
116            || HR_SPACED_DASH.is_match(trimmed)
117            || HR_SPACED_ASTERISK.is_match(trimmed)
118            || HR_SPACED_UNDERSCORE.is_match(trimmed)
119    }
120
121    /// Check if a line might be a Setext heading underline
122    fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
123        if line_num == 0 || line_num >= ctx.lines.len() {
124            return false;
125        }
126
127        let line = ctx.lines[line_num].content(ctx.content).trim();
128        let prev_line = if line_num > 0 {
129            ctx.lines[line_num - 1].content(ctx.content).trim()
130        } else {
131            ""
132        };
133
134        let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
135        let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
136        let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
137        (is_dash_line || is_equals_line) && prev_line_has_content
138    }
139
140    /// Check if headings are separated by horizontal rules
141    fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
142        if !self.config.allow_with_separators || heading_line == 0 {
143            return false;
144        }
145
146        // Look for horizontal rules in the lines before this heading
147        // Check up to 5 lines before the heading for a horizontal rule
148        let search_start = heading_line.saturating_sub(5);
149
150        for line_num in search_start..heading_line {
151            if line_num >= ctx.lines.len() {
152                continue;
153            }
154
155            let line = &ctx.lines[line_num].content(ctx.content);
156            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
157                // Found a horizontal rule before this heading
158                // Check that there's no other heading between the HR and this heading
159                let has_intermediate_heading =
160                    ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
161
162                if !has_intermediate_heading {
163                    return true;
164                }
165            }
166        }
167
168        false
169    }
170}
171
172impl Rule for MD025SingleTitle {
173    fn name(&self) -> &'static str {
174        "MD025"
175    }
176
177    fn description(&self) -> &'static str {
178        "Multiple top-level headings in the same document"
179    }
180
181    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
182        // Early return for empty content
183        if ctx.lines.is_empty() {
184            return Ok(Vec::new());
185        }
186
187        let mut warnings = Vec::new();
188
189        // Check for front matter title if configured
190        let mut _found_title_in_front_matter = false;
191        if !self.config.front_matter_title.is_empty() {
192            // Detect front matter manually
193            let content_lines: Vec<&str> = ctx.content.lines().collect();
194            if content_lines.first().map(|l| l.trim()) == Some("---") {
195                // Look for the end of front matter
196                for (idx, line) in content_lines.iter().enumerate().skip(1) {
197                    if line.trim() == "---" {
198                        // Extract front matter content
199                        let front_matter_content = content_lines[1..idx].join("\n");
200
201                        // Check if it contains a title field
202                        _found_title_in_front_matter = front_matter_content
203                            .lines()
204                            .any(|line| line.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
205                        break;
206                    }
207                }
208            }
209        }
210
211        // Find all headings at the target level using cached information
212        let mut target_level_headings = Vec::new();
213        for (line_num, line_info) in ctx.lines.iter().enumerate() {
214            if let Some(heading) = &line_info.heading
215                && heading.level as usize == self.config.level.as_usize()
216                && heading.is_valid
217            // Skip malformed headings like `#NoSpace`
218            {
219                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
220                if line_info.visual_indent >= 4 || line_info.in_code_block {
221                    continue;
222                }
223                target_level_headings.push(line_num);
224            }
225        }
226
227        // If we have multiple target level headings, flag all subsequent ones (not the first)
228        // unless they are legitimate document sections
229        if target_level_headings.len() > 1 {
230            // Skip the first heading, check the rest for legitimacy
231            for &line_num in &target_level_headings[1..] {
232                if let Some(heading) = &ctx.lines[line_num].heading {
233                    let heading_text = &heading.text;
234
235                    // Check if this heading should be allowed
236                    let should_allow = self.is_document_section_heading(heading_text)
237                        || self.has_separator_before_heading(ctx, line_num);
238
239                    if should_allow {
240                        continue; // Skip flagging this heading
241                    }
242
243                    // Calculate precise character range for the heading text content
244                    let line_content = &ctx.lines[line_num].content(ctx.content);
245                    let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
246                        pos
247                    } else {
248                        // Fallback: find after hash markers for ATX headings
249                        if line_content.trim_start().starts_with('#') {
250                            let trimmed = line_content.trim_start();
251                            let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
252                            let after_hashes = &trimmed[hash_count..];
253                            let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
254                            (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
255                        } else {
256                            0 // Setext headings start at beginning
257                        }
258                    };
259
260                    let (start_line, start_col, end_line, end_col) = calculate_match_range(
261                        line_num + 1, // Convert to 1-indexed
262                        line_content,
263                        text_start_in_line,
264                        heading_text.len(),
265                    );
266
267                    warnings.push(LintWarning {
268                        rule_name: Some(self.name().to_string()),
269                        message: format!(
270                            "Multiple top-level headings (level {}) in the same document",
271                            self.config.level.as_usize()
272                        ),
273                        line: start_line,
274                        column: start_col,
275                        end_line,
276                        end_column: end_col,
277                        severity: Severity::Error,
278                        fix: Some(Fix {
279                            range: ctx.line_index.line_content_range(line_num + 1),
280                            replacement: {
281                                let leading_spaces = line_content.len() - line_content.trim_start().len();
282                                let indentation = " ".repeat(leading_spaces);
283                                if heading_text.is_empty() {
284                                    format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
285                                } else {
286                                    format!(
287                                        "{}{} {}",
288                                        indentation,
289                                        "#".repeat(self.config.level.as_usize() + 1),
290                                        heading_text
291                                    )
292                                }
293                            },
294                        }),
295                    });
296                }
297            }
298        }
299
300        Ok(warnings)
301    }
302
303    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
304        let mut fixed_lines = Vec::new();
305        let mut found_first = false;
306        let mut skip_next = false;
307
308        for (line_num, line_info) in ctx.lines.iter().enumerate() {
309            if skip_next {
310                skip_next = false;
311                continue;
312            }
313
314            if let Some(heading) = &line_info.heading {
315                if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
316                    if !found_first {
317                        found_first = true;
318                        // Keep the first heading as-is
319                        fixed_lines.push(line_info.content(ctx.content).to_string());
320
321                        // For Setext headings, also add the underline
322                        if matches!(
323                            heading.style,
324                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
325                        ) && line_num + 1 < ctx.lines.len()
326                        {
327                            fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
328                            skip_next = true;
329                        }
330                    } else {
331                        // Check if this heading should be allowed
332                        let should_allow = self.is_document_section_heading(&heading.text)
333                            || self.has_separator_before_heading(ctx, line_num);
334
335                        if should_allow {
336                            // Keep the heading as-is
337                            fixed_lines.push(line_info.content(ctx.content).to_string());
338
339                            // For Setext headings, also add the underline
340                            if matches!(
341                                heading.style,
342                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
343                            ) && line_num + 1 < ctx.lines.len()
344                            {
345                                fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
346                                skip_next = true;
347                            }
348                        } else {
349                            // Demote this heading to the next level
350                            let style = match heading.style {
351                                crate::lint_context::HeadingStyle::ATX => {
352                                    if heading.has_closing_sequence {
353                                        crate::rules::heading_utils::HeadingStyle::AtxClosed
354                                    } else {
355                                        crate::rules::heading_utils::HeadingStyle::Atx
356                                    }
357                                }
358                                crate::lint_context::HeadingStyle::Setext1 => {
359                                    // When demoting from level 1 to 2, use Setext2
360                                    if self.config.level.as_usize() == 1 {
361                                        crate::rules::heading_utils::HeadingStyle::Setext2
362                                    } else {
363                                        // For higher levels, use ATX
364                                        crate::rules::heading_utils::HeadingStyle::Atx
365                                    }
366                                }
367                                crate::lint_context::HeadingStyle::Setext2 => {
368                                    // Setext2 can only go to ATX
369                                    crate::rules::heading_utils::HeadingStyle::Atx
370                                }
371                            };
372
373                            let replacement = if heading.text.is_empty() {
374                                // For empty headings, manually construct the replacement
375                                match style {
376                                    crate::rules::heading_utils::HeadingStyle::Atx
377                                    | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
378                                        "#".repeat(self.config.level.as_usize() + 1)
379                                    }
380                                    crate::rules::heading_utils::HeadingStyle::AtxClosed
381                                    | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
382                                        format!(
383                                            "{} {}",
384                                            "#".repeat(self.config.level.as_usize() + 1),
385                                            "#".repeat(self.config.level.as_usize() + 1)
386                                        )
387                                    }
388                                    crate::rules::heading_utils::HeadingStyle::Setext1
389                                    | crate::rules::heading_utils::HeadingStyle::Setext2
390                                    | crate::rules::heading_utils::HeadingStyle::Consistent => {
391                                        // For empty Setext or Consistent, use ATX style
392                                        "#".repeat(self.config.level.as_usize() + 1)
393                                    }
394                                }
395                            } else {
396                                crate::rules::heading_utils::HeadingUtils::convert_heading_style(
397                                    &heading.text,
398                                    (self.config.level.as_usize() + 1) as u32,
399                                    style,
400                                )
401                            };
402
403                            // Preserve original indentation (including tabs)
404                            let line = line_info.content(ctx.content);
405                            let original_indent = &line[..line_info.indent];
406                            fixed_lines.push(format!("{original_indent}{replacement}"));
407
408                            // For Setext headings, skip the original underline
409                            if matches!(
410                                heading.style,
411                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
412                            ) && line_num + 1 < ctx.lines.len()
413                            {
414                                skip_next = true;
415                            }
416                        }
417                    }
418                } else {
419                    // Not a target level heading, keep as-is
420                    fixed_lines.push(line_info.content(ctx.content).to_string());
421
422                    // For Setext headings, also add the underline
423                    if matches!(
424                        heading.style,
425                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
426                    ) && line_num + 1 < ctx.lines.len()
427                    {
428                        fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
429                        skip_next = true;
430                    }
431                }
432            } else {
433                // Not a heading line, keep as-is
434                fixed_lines.push(line_info.content(ctx.content).to_string());
435            }
436        }
437
438        let result = fixed_lines.join("\n");
439        if ctx.content.ends_with('\n') {
440            Ok(result + "\n")
441        } else {
442            Ok(result)
443        }
444    }
445
446    /// Get the category of this rule for selective processing
447    fn category(&self) -> RuleCategory {
448        RuleCategory::Heading
449    }
450
451    /// Check if this rule should be skipped for performance
452    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
453        // Skip if content is empty
454        if ctx.content.is_empty() {
455            return true;
456        }
457
458        // Skip if no heading markers at all
459        if !ctx.likely_has_headings() {
460            return true;
461        }
462
463        // Fast path: count target level headings efficiently
464        let mut target_level_count = 0;
465        for line_info in &ctx.lines {
466            if let Some(heading) = &line_info.heading
467                && heading.level as usize == self.config.level.as_usize()
468            {
469                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
470                if line_info.visual_indent >= 4 || line_info.in_code_block {
471                    continue;
472                }
473                target_level_count += 1;
474
475                // If we find more than 1, we need to run the full check
476                // to determine if they're legitimate document sections
477                if target_level_count > 1 {
478                    return false;
479                }
480            }
481        }
482
483        // If we have 0 or 1 target level headings, skip the rule
484        target_level_count <= 1
485    }
486
487    fn as_any(&self) -> &dyn std::any::Any {
488        self
489    }
490
491    fn default_config_section(&self) -> Option<(String, toml::Value)> {
492        let json_value = serde_json::to_value(&self.config).ok()?;
493        Some((
494            self.name().to_string(),
495            crate::rule_config_serde::json_to_toml_value(&json_value)?,
496        ))
497    }
498
499    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
500    where
501        Self: Sized,
502    {
503        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
504        Box::new(Self::from_config_struct(rule_config))
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_with_cached_headings() {
514        let rule = MD025SingleTitle::default();
515
516        // Test with only one level-1 heading
517        let content = "# Title\n\n## Section 1\n\n## Section 2";
518        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519        let result = rule.check(&ctx).unwrap();
520        assert!(result.is_empty());
521
522        // Test with multiple level-1 headings (non-section names) - should flag
523        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
524        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525        let result = rule.check(&ctx).unwrap();
526        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
527        assert_eq!(result[0].line, 5);
528
529        // Test with front matter title and a level-1 heading
530        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
531        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533        assert!(result.is_empty(), "Should not flag a single title after front matter");
534    }
535
536    #[test]
537    fn test_allow_document_sections() {
538        // Need to create rule with allow_document_sections = true
539        let config = md025_config::MD025Config {
540            allow_document_sections: true,
541            ..Default::default()
542        };
543        let rule = MD025SingleTitle::from_config_struct(config);
544
545        // Test valid document sections that should NOT be flagged
546        let valid_cases = vec![
547            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
548            "# Introduction\n\nContent here\n\n# References\n\nRef content",
549            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
550            "# Manual\n\nContent\n\n# Index\n\nIndex content",
551            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
552            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
553            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
554        ];
555
556        for case in valid_cases {
557            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
558            let result = rule.check(&ctx).unwrap();
559            assert!(result.is_empty(), "Should not flag document sections in: {case}");
560        }
561
562        // Test invalid cases that should still be flagged
563        let invalid_cases = vec![
564            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
565            "# First\n\nContent\n\n# Second Title\n\nMore content",
566        ];
567
568        for case in invalid_cases {
569            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
570            let result = rule.check(&ctx).unwrap();
571            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
572        }
573    }
574
575    #[test]
576    fn test_strict_mode() {
577        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
578
579        // Even document sections should be flagged in strict mode
580        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
581        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582        let result = rule.check(&ctx).unwrap();
583        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
584    }
585
586    #[test]
587    fn test_bounds_checking_bug() {
588        // Test case that could trigger bounds error in fix generation
589        // When col + self.config.level.as_usize() exceeds line_content.len()
590        let rule = MD025SingleTitle::default();
591
592        // Create content with very short second heading
593        let content = "# First\n#";
594        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595
596        // This should not panic
597        let result = rule.check(&ctx);
598        assert!(result.is_ok());
599
600        // Test the fix as well
601        let fix_result = rule.fix(&ctx);
602        assert!(fix_result.is_ok());
603    }
604
605    #[test]
606    fn test_bounds_checking_edge_case() {
607        // Test case that specifically targets the bounds checking fix
608        // Create a heading where col + self.config.level.as_usize() would exceed line length
609        let rule = MD025SingleTitle::default();
610
611        // Create content where the second heading is just "#" (length 1)
612        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
613        // This should not exceed bounds for "#" but tests the edge case
614        let content = "# First Title\n#";
615        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616
617        // This should not panic and should handle the edge case gracefully
618        let result = rule.check(&ctx);
619        assert!(result.is_ok());
620
621        if let Ok(warnings) = result
622            && !warnings.is_empty()
623        {
624            // Check that the fix doesn't cause a panic
625            let fix_result = rule.fix(&ctx);
626            assert!(fix_result.is_ok());
627
628            // The fix should produce valid content
629            if let Ok(fixed_content) = fix_result {
630                assert!(!fixed_content.is_empty());
631                // Should convert the second "#" to "##" (or "## " if there's content)
632                assert!(fixed_content.contains("##"));
633            }
634        }
635    }
636
637    #[test]
638    fn test_horizontal_rule_separators() {
639        // Need to create rule with allow_with_separators = true
640        let config = md025_config::MD025Config {
641            allow_with_separators: true,
642            ..Default::default()
643        };
644        let rule = MD025SingleTitle::from_config_struct(config);
645
646        // Test that headings separated by horizontal rules are allowed
647        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n***\n\n# Third Title\n\nFinal content.";
648        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649        let result = rule.check(&ctx).unwrap();
650        assert!(
651            result.is_empty(),
652            "Should not flag headings separated by horizontal rules"
653        );
654
655        // Test that headings without separators are still flagged
656        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n# Third Title\n\nNo separator before this one.";
657        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658        let result = rule.check(&ctx).unwrap();
659        assert_eq!(result.len(), 1, "Should flag the heading without separator");
660        assert_eq!(result[0].line, 11); // Third title on line 11
661
662        // Test with allow_with_separators = false
663        let strict_rule = MD025SingleTitle::strict();
664        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
665        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666        let result = strict_rule.check(&ctx).unwrap();
667        assert_eq!(
668            result.len(),
669            1,
670            "Strict mode should flag all multiple H1s regardless of separators"
671        );
672    }
673
674    #[test]
675    fn test_python_comments_in_code_blocks() {
676        let rule = MD025SingleTitle::default();
677
678        // Test that Python comments in code blocks are not treated as headers
679        let content = "# Main Title\n\n```python\n# This is a Python comment, not a heading\nprint('Hello')\n```\n\n## Section\n\nMore content.";
680        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681        let result = rule.check(&ctx).unwrap();
682        assert!(
683            result.is_empty(),
684            "Should not flag Python comments in code blocks as headings"
685        );
686
687        // Test the fix method doesn't modify Python comments
688        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
689        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690        let fixed = rule.fix(&ctx).unwrap();
691        assert!(
692            fixed.contains("# Python comment"),
693            "Fix should preserve Python comments in code blocks"
694        );
695        assert!(
696            fixed.contains("## Second Title"),
697            "Fix should demote the actual second heading"
698        );
699    }
700}