Skip to main content

rumdl_lib/rules/
md025_single_title.rs

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