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