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.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::Warning,
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                            // Add indentation
404                            let indentation = " ".repeat(line_info.indent);
405                            fixed_lines.push(format!("{indentation}{replacement}"));
406
407                            // For Setext headings, skip the original underline
408                            if matches!(
409                                heading.style,
410                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
411                            ) && line_num + 1 < ctx.lines.len()
412                            {
413                                skip_next = true;
414                            }
415                        }
416                    }
417                } else {
418                    // Not a target level heading, keep as-is
419                    fixed_lines.push(line_info.content(ctx.content).to_string());
420
421                    // For Setext headings, also add the underline
422                    if matches!(
423                        heading.style,
424                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
425                    ) && line_num + 1 < ctx.lines.len()
426                    {
427                        fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
428                        skip_next = true;
429                    }
430                }
431            } else {
432                // Not a heading line, keep as-is
433                fixed_lines.push(line_info.content(ctx.content).to_string());
434            }
435        }
436
437        let result = fixed_lines.join("\n");
438        if ctx.content.ends_with('\n') {
439            Ok(result + "\n")
440        } else {
441            Ok(result)
442        }
443    }
444
445    /// Get the category of this rule for selective processing
446    fn category(&self) -> RuleCategory {
447        RuleCategory::Heading
448    }
449
450    /// Check if this rule should be skipped for performance
451    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
452        // Skip if content is empty
453        if ctx.content.is_empty() {
454            return true;
455        }
456
457        // Skip if no heading markers at all
458        if !ctx.likely_has_headings() {
459            return true;
460        }
461
462        // Fast path: count target level headings efficiently
463        let mut target_level_count = 0;
464        for line_info in &ctx.lines {
465            if let Some(heading) = &line_info.heading
466                && heading.level as usize == self.config.level.as_usize()
467            {
468                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
469                if line_info.indent >= 4 || line_info.in_code_block {
470                    continue;
471                }
472                target_level_count += 1;
473
474                // If we find more than 1, we need to run the full check
475                // to determine if they're legitimate document sections
476                if target_level_count > 1 {
477                    return false;
478                }
479            }
480        }
481
482        // If we have 0 or 1 target level headings, skip the rule
483        target_level_count <= 1
484    }
485
486    fn as_any(&self) -> &dyn std::any::Any {
487        self
488    }
489
490    fn default_config_section(&self) -> Option<(String, toml::Value)> {
491        let json_value = serde_json::to_value(&self.config).ok()?;
492        Some((
493            self.name().to_string(),
494            crate::rule_config_serde::json_to_toml_value(&json_value)?,
495        ))
496    }
497
498    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
499    where
500        Self: Sized,
501    {
502        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
503        Box::new(Self::from_config_struct(rule_config))
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_with_cached_headings() {
513        let rule = MD025SingleTitle::default();
514
515        // Test with only one level-1 heading
516        let content = "# Title\n\n## Section 1\n\n## Section 2";
517        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518        let result = rule.check(&ctx).unwrap();
519        assert!(result.is_empty());
520
521        // Test with multiple level-1 headings (non-section names) - should flag
522        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
523        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let result = rule.check(&ctx).unwrap();
525        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
526        assert_eq!(result[0].line, 5);
527
528        // Test with front matter title and a level-1 heading
529        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
530        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531        let result = rule.check(&ctx).unwrap();
532        assert!(result.is_empty(), "Should not flag a single title after front matter");
533    }
534
535    #[test]
536    fn test_allow_document_sections() {
537        // Need to create rule with allow_document_sections = true
538        let config = md025_config::MD025Config {
539            allow_document_sections: true,
540            ..Default::default()
541        };
542        let rule = MD025SingleTitle::from_config_struct(config);
543
544        // Test valid document sections that should NOT be flagged
545        let valid_cases = vec![
546            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
547            "# Introduction\n\nContent here\n\n# References\n\nRef content",
548            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
549            "# Manual\n\nContent\n\n# Index\n\nIndex content",
550            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
551            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
552            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
553        ];
554
555        for case in valid_cases {
556            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
557            let result = rule.check(&ctx).unwrap();
558            assert!(result.is_empty(), "Should not flag document sections in: {case}");
559        }
560
561        // Test invalid cases that should still be flagged
562        let invalid_cases = vec![
563            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
564            "# First\n\nContent\n\n# Second Title\n\nMore content",
565        ];
566
567        for case in invalid_cases {
568            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
569            let result = rule.check(&ctx).unwrap();
570            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
571        }
572    }
573
574    #[test]
575    fn test_strict_mode() {
576        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
577
578        // Even document sections should be flagged in strict mode
579        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
580        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581        let result = rule.check(&ctx).unwrap();
582        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
583    }
584
585    #[test]
586    fn test_bounds_checking_bug() {
587        // Test case that could trigger bounds error in fix generation
588        // When col + self.config.level.as_usize() exceeds line_content.len()
589        let rule = MD025SingleTitle::default();
590
591        // Create content with very short second heading
592        let content = "# First\n#";
593        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594
595        // This should not panic
596        let result = rule.check(&ctx);
597        assert!(result.is_ok());
598
599        // Test the fix as well
600        let fix_result = rule.fix(&ctx);
601        assert!(fix_result.is_ok());
602    }
603
604    #[test]
605    fn test_bounds_checking_edge_case() {
606        // Test case that specifically targets the bounds checking fix
607        // Create a heading where col + self.config.level.as_usize() would exceed line length
608        let rule = MD025SingleTitle::default();
609
610        // Create content where the second heading is just "#" (length 1)
611        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
612        // This should not exceed bounds for "#" but tests the edge case
613        let content = "# First Title\n#";
614        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615
616        // This should not panic and should handle the edge case gracefully
617        let result = rule.check(&ctx);
618        assert!(result.is_ok());
619
620        if let Ok(warnings) = result
621            && !warnings.is_empty()
622        {
623            // Check that the fix doesn't cause a panic
624            let fix_result = rule.fix(&ctx);
625            assert!(fix_result.is_ok());
626
627            // The fix should produce valid content
628            if let Ok(fixed_content) = fix_result {
629                assert!(!fixed_content.is_empty());
630                // Should convert the second "#" to "##" (or "## " if there's content)
631                assert!(fixed_content.contains("##"));
632            }
633        }
634    }
635
636    #[test]
637    fn test_horizontal_rule_separators() {
638        // Need to create rule with allow_with_separators = true
639        let config = md025_config::MD025Config {
640            allow_with_separators: true,
641            ..Default::default()
642        };
643        let rule = MD025SingleTitle::from_config_struct(config);
644
645        // Test that headings separated by horizontal rules are allowed
646        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.";
647        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648        let result = rule.check(&ctx).unwrap();
649        assert!(
650            result.is_empty(),
651            "Should not flag headings separated by horizontal rules"
652        );
653
654        // Test that headings without separators are still flagged
655        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.";
656        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657        let result = rule.check(&ctx).unwrap();
658        assert_eq!(result.len(), 1, "Should flag the heading without separator");
659        assert_eq!(result[0].line, 11); // Third title on line 11
660
661        // Test with allow_with_separators = false
662        let strict_rule = MD025SingleTitle::strict();
663        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
664        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665        let result = strict_rule.check(&ctx).unwrap();
666        assert_eq!(
667            result.len(),
668            1,
669            "Strict mode should flag all multiple H1s regardless of separators"
670        );
671    }
672
673    #[test]
674    fn test_python_comments_in_code_blocks() {
675        let rule = MD025SingleTitle::default();
676
677        // Test that Python comments in code blocks are not treated as headers
678        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.";
679        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let result = rule.check(&ctx).unwrap();
681        assert!(
682            result.is_empty(),
683            "Should not flag Python comments in code blocks as headings"
684        );
685
686        // Test the fix method doesn't modify Python comments
687        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
688        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let fixed = rule.fix(&ctx).unwrap();
690        assert!(
691            fixed.contains("# Python comment"),
692            "Fix should preserve Python comments in code blocks"
693        );
694        assert!(
695            fixed.contains("## Second Title"),
696            "Fix should demote the actual second heading"
697        );
698    }
699}