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()),
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.content.contains('#') && !ctx.content.contains('=') && !ctx.content.contains('-') {
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 as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
484        None
485    }
486
487    fn default_config_section(&self) -> Option<(String, toml::Value)> {
488        let json_value = serde_json::to_value(&self.config).ok()?;
489        Some((
490            self.name().to_string(),
491            crate::rule_config_serde::json_to_toml_value(&json_value)?,
492        ))
493    }
494
495    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
496    where
497        Self: Sized,
498    {
499        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
500        Box::new(Self::from_config_struct(rule_config))
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn test_with_cached_headings() {
510        let rule = MD025SingleTitle::default();
511
512        // Test with only one level-1 heading
513        let content = "# Title\n\n## Section 1\n\n## Section 2";
514        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
515        let result = rule.check(&ctx).unwrap();
516        assert!(result.is_empty());
517
518        // Test with multiple level-1 headings (non-section names) - should flag
519        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
520        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
521        let result = rule.check(&ctx).unwrap();
522        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
523        assert_eq!(result[0].line, 5);
524
525        // Test with front matter title and a level-1 heading
526        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
527        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
528        let result = rule.check(&ctx).unwrap();
529        assert!(result.is_empty(), "Should not flag a single title after front matter");
530    }
531
532    #[test]
533    fn test_allow_document_sections() {
534        let rule = MD025SingleTitle::default(); // Has allow_document_sections = true
535
536        // Test valid document sections that should NOT be flagged
537        let valid_cases = vec![
538            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
539            "# Introduction\n\nContent here\n\n# References\n\nRef content",
540            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
541            "# Manual\n\nContent\n\n# Index\n\nIndex content",
542            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
543            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
544            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
545        ];
546
547        for case in valid_cases {
548            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
549            let result = rule.check(&ctx).unwrap();
550            assert!(result.is_empty(), "Should not flag document sections in: {case}");
551        }
552
553        // Test invalid cases that should still be flagged
554        let invalid_cases = vec![
555            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
556            "# First\n\nContent\n\n# Second Title\n\nMore content",
557        ];
558
559        for case in invalid_cases {
560            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
561            let result = rule.check(&ctx).unwrap();
562            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
563        }
564    }
565
566    #[test]
567    fn test_strict_mode() {
568        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
569
570        // Even document sections should be flagged in strict mode
571        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
572        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573        let result = rule.check(&ctx).unwrap();
574        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
575    }
576
577    #[test]
578    fn test_bounds_checking_bug() {
579        // Test case that could trigger bounds error in fix generation
580        // When col + self.config.level exceeds line_content.len()
581        let rule = MD025SingleTitle::default();
582
583        // Create content with very short second heading
584        let content = "# First\n#";
585        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586
587        // This should not panic
588        let result = rule.check(&ctx);
589        assert!(result.is_ok());
590
591        // Test the fix as well
592        let fix_result = rule.fix(&ctx);
593        assert!(fix_result.is_ok());
594    }
595
596    #[test]
597    fn test_bounds_checking_edge_case() {
598        // Test case that specifically targets the bounds checking fix
599        // Create a heading where col + self.config.level would exceed line length
600        let rule = MD025SingleTitle::default();
601
602        // Create content where the second heading is just "#" (length 1)
603        // col will be 0, self.config.level is 1, so col + self.config.level = 1
604        // This should not exceed bounds for "#" but tests the edge case
605        let content = "# First Title\n#";
606        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607
608        // This should not panic and should handle the edge case gracefully
609        let result = rule.check(&ctx);
610        assert!(result.is_ok());
611
612        if let Ok(warnings) = result
613            && !warnings.is_empty()
614        {
615            // Check that the fix doesn't cause a panic
616            let fix_result = rule.fix(&ctx);
617            assert!(fix_result.is_ok());
618
619            // The fix should produce valid content
620            if let Ok(fixed_content) = fix_result {
621                assert!(!fixed_content.is_empty());
622                // Should convert the second "#" to "##" (or "## " if there's content)
623                assert!(fixed_content.contains("##"));
624            }
625        }
626    }
627
628    #[test]
629    fn test_horizontal_rule_separators() {
630        let rule = MD025SingleTitle::default(); // Has allow_with_separators = true
631
632        // Test that headings separated by horizontal rules are allowed
633        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.";
634        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635        let result = rule.check(&ctx).unwrap();
636        assert!(
637            result.is_empty(),
638            "Should not flag headings separated by horizontal rules"
639        );
640
641        // Test that headings without separators are still flagged
642        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.";
643        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
644        let result = rule.check(&ctx).unwrap();
645        assert_eq!(result.len(), 1, "Should flag the heading without separator");
646        assert_eq!(result[0].line, 11); // Third title on line 11
647
648        // Test with allow_with_separators = false
649        let strict_rule = MD025SingleTitle::strict();
650        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
651        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
652        let result = strict_rule.check(&ctx).unwrap();
653        assert_eq!(
654            result.len(),
655            1,
656            "Strict mode should flag all multiple H1s regardless of separators"
657        );
658    }
659
660    #[test]
661    fn test_python_comments_in_code_blocks() {
662        let rule = MD025SingleTitle::default();
663
664        // Test that Python comments in code blocks are not treated as headers
665        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.";
666        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
667        let result = rule.check(&ctx).unwrap();
668        assert!(
669            result.is_empty(),
670            "Should not flag Python comments in code blocks as headings"
671        );
672
673        // Test the fix method doesn't modify Python comments
674        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
675        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
676        let fixed = rule.fix(&ctx).unwrap();
677        assert!(
678            fixed.contains("# Python comment"),
679            "Fix should preserve Python comments in code blocks"
680        );
681        assert!(
682            fixed.contains("## Second Title"),
683            "Fix should demote the actual second heading"
684        );
685    }
686}