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::{LineIndex, 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.trim();
128        let prev_line = if line_num > 0 {
129            ctx.lines[line_num - 1].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;
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 line_index = LineIndex::new(ctx.content.to_string());
188        let mut warnings = Vec::new();
189
190        // Check for front matter title if configured
191        let mut _found_title_in_front_matter = false;
192        if !self.config.front_matter_title.is_empty() {
193            // Detect front matter manually
194            let content_lines: Vec<&str> = ctx.content.lines().collect();
195            if content_lines.first().map(|l| l.trim()) == Some("---") {
196                // Look for the end of front matter
197                for (idx, line) in content_lines.iter().enumerate().skip(1) {
198                    if line.trim() == "---" {
199                        // Extract front matter content
200                        let front_matter_content = content_lines[1..idx].join("\n");
201
202                        // Check if it contains a title field
203                        _found_title_in_front_matter = front_matter_content
204                            .lines()
205                            .any(|line| line.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
206                        break;
207                    }
208                }
209            }
210        }
211
212        // Find all headings at the target level using cached information
213        let mut target_level_headings = Vec::new();
214        for (line_num, line_info) in ctx.lines.iter().enumerate() {
215            if let Some(heading) = &line_info.heading
216                && heading.level as usize == self.config.level.as_usize()
217            {
218                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
219                if line_info.indent >= 4 || line_info.in_code_block {
220                    continue;
221                }
222                target_level_headings.push(line_num);
223            }
224        }
225
226        // If we have multiple target level headings, flag all subsequent ones (not the first)
227        // unless they are legitimate document sections
228        if target_level_headings.len() > 1 {
229            // Skip the first heading, check the rest for legitimacy
230            for &line_num in &target_level_headings[1..] {
231                if let Some(heading) = &ctx.lines[line_num].heading {
232                    let heading_text = &heading.text;
233
234                    // Check if this heading should be allowed
235                    let should_allow = self.is_document_section_heading(heading_text)
236                        || self.has_separator_before_heading(ctx, line_num);
237
238                    if should_allow {
239                        continue; // Skip flagging this heading
240                    }
241
242                    // Calculate precise character range for the heading text content
243                    let line_content = &ctx.lines[line_num].content;
244                    let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
245                        pos
246                    } else {
247                        // Fallback: find after hash markers for ATX headings
248                        if line_content.trim_start().starts_with('#') {
249                            let trimmed = line_content.trim_start();
250                            let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
251                            let after_hashes = &trimmed[hash_count..];
252                            let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
253                            (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
254                        } else {
255                            0 // Setext headings start at beginning
256                        }
257                    };
258
259                    let (start_line, start_col, end_line, end_col) = calculate_match_range(
260                        line_num + 1, // Convert to 1-indexed
261                        line_content,
262                        text_start_in_line,
263                        heading_text.len(),
264                    );
265
266                    warnings.push(LintWarning {
267                        rule_name: Some(self.name().to_string()),
268                        message: format!(
269                            "Multiple top-level headings (level {}) in the same document",
270                            self.config.level.as_usize()
271                        ),
272                        line: start_line,
273                        column: start_col,
274                        end_line,
275                        end_column: end_col,
276                        severity: Severity::Warning,
277                        fix: Some(Fix {
278                            range: line_index.line_content_range(line_num + 1),
279                            replacement: {
280                                let leading_spaces = line_content.len() - line_content.trim_start().len();
281                                let indentation = " ".repeat(leading_spaces);
282                                if heading_text.is_empty() {
283                                    format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
284                                } else {
285                                    format!(
286                                        "{}{} {}",
287                                        indentation,
288                                        "#".repeat(self.config.level.as_usize() + 1),
289                                        heading_text
290                                    )
291                                }
292                            },
293                        }),
294                    });
295                }
296            }
297        }
298
299        Ok(warnings)
300    }
301
302    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
303        let mut fixed_lines = Vec::new();
304        let mut found_first = false;
305        let mut skip_next = false;
306
307        for (line_num, line_info) in ctx.lines.iter().enumerate() {
308            if skip_next {
309                skip_next = false;
310                continue;
311            }
312
313            if let Some(heading) = &line_info.heading {
314                if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
315                    if !found_first {
316                        found_first = true;
317                        // Keep the first heading as-is
318                        fixed_lines.push(line_info.content.clone());
319
320                        // For Setext headings, also add the underline
321                        if matches!(
322                            heading.style,
323                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
324                        ) && line_num + 1 < ctx.lines.len()
325                        {
326                            fixed_lines.push(ctx.lines[line_num + 1].content.clone());
327                            skip_next = true;
328                        }
329                    } else {
330                        // Check if this heading should be allowed
331                        let should_allow = self.is_document_section_heading(&heading.text)
332                            || self.has_separator_before_heading(ctx, line_num);
333
334                        if should_allow {
335                            // Keep the heading as-is
336                            fixed_lines.push(line_info.content.clone());
337
338                            // For Setext headings, also add the underline
339                            if matches!(
340                                heading.style,
341                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
342                            ) && line_num + 1 < ctx.lines.len()
343                            {
344                                fixed_lines.push(ctx.lines[line_num + 1].content.clone());
345                                skip_next = true;
346                            }
347                        } else {
348                            // Demote this heading to the next level
349                            let style = match heading.style {
350                                crate::lint_context::HeadingStyle::ATX => {
351                                    if heading.has_closing_sequence {
352                                        crate::rules::heading_utils::HeadingStyle::AtxClosed
353                                    } else {
354                                        crate::rules::heading_utils::HeadingStyle::Atx
355                                    }
356                                }
357                                crate::lint_context::HeadingStyle::Setext1 => {
358                                    // When demoting from level 1 to 2, use Setext2
359                                    if self.config.level.as_usize() == 1 {
360                                        crate::rules::heading_utils::HeadingStyle::Setext2
361                                    } else {
362                                        // For higher levels, use ATX
363                                        crate::rules::heading_utils::HeadingStyle::Atx
364                                    }
365                                }
366                                crate::lint_context::HeadingStyle::Setext2 => {
367                                    // Setext2 can only go to ATX
368                                    crate::rules::heading_utils::HeadingStyle::Atx
369                                }
370                            };
371
372                            let replacement = if heading.text.is_empty() {
373                                // For empty headings, manually construct the replacement
374                                match style {
375                                    crate::rules::heading_utils::HeadingStyle::Atx
376                                    | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
377                                        "#".repeat(self.config.level.as_usize() + 1)
378                                    }
379                                    crate::rules::heading_utils::HeadingStyle::AtxClosed
380                                    | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
381                                        format!(
382                                            "{} {}",
383                                            "#".repeat(self.config.level.as_usize() + 1),
384                                            "#".repeat(self.config.level.as_usize() + 1)
385                                        )
386                                    }
387                                    crate::rules::heading_utils::HeadingStyle::Setext1
388                                    | crate::rules::heading_utils::HeadingStyle::Setext2
389                                    | crate::rules::heading_utils::HeadingStyle::Consistent => {
390                                        // For empty Setext or Consistent, use ATX style
391                                        "#".repeat(self.config.level.as_usize() + 1)
392                                    }
393                                }
394                            } else {
395                                crate::rules::heading_utils::HeadingUtils::convert_heading_style(
396                                    &heading.text,
397                                    (self.config.level.as_usize() + 1) as u32,
398                                    style,
399                                )
400                            };
401
402                            // Add indentation
403                            let indentation = " ".repeat(line_info.indent);
404                            fixed_lines.push(format!("{indentation}{replacement}"));
405
406                            // For Setext headings, skip the original underline
407                            if matches!(
408                                heading.style,
409                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
410                            ) && line_num + 1 < ctx.lines.len()
411                            {
412                                skip_next = true;
413                            }
414                        }
415                    }
416                } else {
417                    // Not a target level heading, keep as-is
418                    fixed_lines.push(line_info.content.clone());
419
420                    // For Setext headings, also add the underline
421                    if matches!(
422                        heading.style,
423                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
424                    ) && line_num + 1 < ctx.lines.len()
425                    {
426                        fixed_lines.push(ctx.lines[line_num + 1].content.clone());
427                        skip_next = true;
428                    }
429                }
430            } else {
431                // Not a heading line, keep as-is
432                fixed_lines.push(line_info.content.clone());
433            }
434        }
435
436        let result = fixed_lines.join("\n");
437        if ctx.content.ends_with('\n') {
438            Ok(result + "\n")
439        } else {
440            Ok(result)
441        }
442    }
443
444    /// Get the category of this rule for selective processing
445    fn category(&self) -> RuleCategory {
446        RuleCategory::Heading
447    }
448
449    /// Check if this rule should be skipped for performance
450    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
451        // Skip if content is empty
452        if ctx.content.is_empty() {
453            return true;
454        }
455
456        // Skip if no heading markers at all
457        if !ctx.likely_has_headings() {
458            return true;
459        }
460
461        // Fast path: count target level headings efficiently
462        let mut target_level_count = 0;
463        for line_info in &ctx.lines {
464            if let Some(heading) = &line_info.heading
465                && heading.level as usize == self.config.level.as_usize()
466            {
467                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
468                if line_info.indent >= 4 || line_info.in_code_block {
469                    continue;
470                }
471                target_level_count += 1;
472
473                // If we find more than 1, we need to run the full check
474                // to determine if they're legitimate document sections
475                if target_level_count > 1 {
476                    return false;
477                }
478            }
479        }
480
481        // If we have 0 or 1 target level headings, skip the rule
482        target_level_count <= 1
483    }
484
485    fn as_any(&self) -> &dyn std::any::Any {
486        self
487    }
488
489    fn default_config_section(&self) -> Option<(String, toml::Value)> {
490        let json_value = serde_json::to_value(&self.config).ok()?;
491        Some((
492            self.name().to_string(),
493            crate::rule_config_serde::json_to_toml_value(&json_value)?,
494        ))
495    }
496
497    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
498    where
499        Self: Sized,
500    {
501        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
502        Box::new(Self::from_config_struct(rule_config))
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_with_cached_headings() {
512        let rule = MD025SingleTitle::default();
513
514        // Test with only one level-1 heading
515        let content = "# Title\n\n## Section 1\n\n## Section 2";
516        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
517        let result = rule.check(&ctx).unwrap();
518        assert!(result.is_empty());
519
520        // Test with multiple level-1 headings (non-section names) - should flag
521        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
522        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
523        let result = rule.check(&ctx).unwrap();
524        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
525        assert_eq!(result[0].line, 5);
526
527        // Test with front matter title and a level-1 heading
528        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
529        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530        let result = rule.check(&ctx).unwrap();
531        assert!(result.is_empty(), "Should not flag a single title after front matter");
532    }
533
534    #[test]
535    fn test_allow_document_sections() {
536        // Need to create rule with allow_document_sections = true
537        let config = md025_config::MD025Config {
538            allow_document_sections: true,
539            ..Default::default()
540        };
541        let rule = MD025SingleTitle::from_config_struct(config);
542
543        // Test valid document sections that should NOT be flagged
544        let valid_cases = vec![
545            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
546            "# Introduction\n\nContent here\n\n# References\n\nRef content",
547            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
548            "# Manual\n\nContent\n\n# Index\n\nIndex content",
549            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
550            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
551            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
552        ];
553
554        for case in valid_cases {
555            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
556            let result = rule.check(&ctx).unwrap();
557            assert!(result.is_empty(), "Should not flag document sections in: {case}");
558        }
559
560        // Test invalid cases that should still be flagged
561        let invalid_cases = vec![
562            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
563            "# First\n\nContent\n\n# Second Title\n\nMore content",
564        ];
565
566        for case in invalid_cases {
567            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
568            let result = rule.check(&ctx).unwrap();
569            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
570        }
571    }
572
573    #[test]
574    fn test_strict_mode() {
575        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
576
577        // Even document sections should be flagged in strict mode
578        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
579        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
580        let result = rule.check(&ctx).unwrap();
581        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
582    }
583
584    #[test]
585    fn test_bounds_checking_bug() {
586        // Test case that could trigger bounds error in fix generation
587        // When col + self.config.level.as_usize() exceeds line_content.len()
588        let rule = MD025SingleTitle::default();
589
590        // Create content with very short second heading
591        let content = "# First\n#";
592        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
593
594        // This should not panic
595        let result = rule.check(&ctx);
596        assert!(result.is_ok());
597
598        // Test the fix as well
599        let fix_result = rule.fix(&ctx);
600        assert!(fix_result.is_ok());
601    }
602
603    #[test]
604    fn test_bounds_checking_edge_case() {
605        // Test case that specifically targets the bounds checking fix
606        // Create a heading where col + self.config.level.as_usize() would exceed line length
607        let rule = MD025SingleTitle::default();
608
609        // Create content where the second heading is just "#" (length 1)
610        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
611        // This should not exceed bounds for "#" but tests the edge case
612        let content = "# First Title\n#";
613        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
614
615        // This should not panic and should handle the edge case gracefully
616        let result = rule.check(&ctx);
617        assert!(result.is_ok());
618
619        if let Ok(warnings) = result
620            && !warnings.is_empty()
621        {
622            // Check that the fix doesn't cause a panic
623            let fix_result = rule.fix(&ctx);
624            assert!(fix_result.is_ok());
625
626            // The fix should produce valid content
627            if let Ok(fixed_content) = fix_result {
628                assert!(!fixed_content.is_empty());
629                // Should convert the second "#" to "##" (or "## " if there's content)
630                assert!(fixed_content.contains("##"));
631            }
632        }
633    }
634
635    #[test]
636    fn test_horizontal_rule_separators() {
637        // Need to create rule with allow_with_separators = true
638        let config = md025_config::MD025Config {
639            allow_with_separators: true,
640            ..Default::default()
641        };
642        let rule = MD025SingleTitle::from_config_struct(config);
643
644        // Test that headings separated by horizontal rules are allowed
645        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.";
646        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
647        let result = rule.check(&ctx).unwrap();
648        assert!(
649            result.is_empty(),
650            "Should not flag headings separated by horizontal rules"
651        );
652
653        // Test that headings without separators are still flagged
654        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.";
655        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
656        let result = rule.check(&ctx).unwrap();
657        assert_eq!(result.len(), 1, "Should flag the heading without separator");
658        assert_eq!(result[0].line, 11); // Third title on line 11
659
660        // Test with allow_with_separators = false
661        let strict_rule = MD025SingleTitle::strict();
662        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
663        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
664        let result = strict_rule.check(&ctx).unwrap();
665        assert_eq!(
666            result.len(),
667            1,
668            "Strict mode should flag all multiple H1s regardless of separators"
669        );
670    }
671
672    #[test]
673    fn test_python_comments_in_code_blocks() {
674        let rule = MD025SingleTitle::default();
675
676        // Test that Python comments in code blocks are not treated as headers
677        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.";
678        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
679        let result = rule.check(&ctx).unwrap();
680        assert!(
681            result.is_empty(),
682            "Should not flag Python comments in code blocks as headings"
683        );
684
685        // Test the fix method doesn't modify Python comments
686        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
687        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
688        let fixed = rule.fix(&ctx).unwrap();
689        assert!(
690            fixed.contains("# Python comment"),
691            "Fix should preserve Python comments in code blocks"
692        );
693        assert!(
694            fixed.contains("## Second Title"),
695            "Fix should demote the actual second heading"
696        );
697    }
698}