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