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 the document's frontmatter contains a title field matching the configured key
48    fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
49        if self.config.front_matter_title.is_empty() {
50            return false;
51        }
52
53        let content_lines = ctx.raw_lines();
54        if content_lines.first().map(|l| l.trim()) != Some("---") {
55            return false;
56        }
57
58        for (idx, line) in content_lines.iter().enumerate().skip(1) {
59            if line.trim() == "---" {
60                let front_matter_content = content_lines[1..idx].join("\n");
61                return front_matter_content
62                    .lines()
63                    .any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
64            }
65        }
66
67        false
68    }
69
70    /// Check if a heading text suggests it's a legitimate document section
71    fn is_document_section_heading(&self, heading_text: &str) -> bool {
72        if !self.config.allow_document_sections {
73            return false;
74        }
75
76        let lower_text = heading_text.to_lowercase();
77
78        // Common section names that are legitimate as separate H1s
79        let section_indicators = [
80            "appendix",
81            "appendices",
82            "reference",
83            "references",
84            "bibliography",
85            "index",
86            "indices",
87            "glossary",
88            "glossaries",
89            "conclusion",
90            "conclusions",
91            "summary",
92            "executive summary",
93            "acknowledgment",
94            "acknowledgments",
95            "acknowledgement",
96            "acknowledgements",
97            "about",
98            "contact",
99            "license",
100            "legal",
101            "changelog",
102            "change log",
103            "history",
104            "faq",
105            "frequently asked questions",
106            "troubleshooting",
107            "support",
108            "installation",
109            "setup",
110            "getting started",
111            "api reference",
112            "api documentation",
113            "examples",
114            "tutorials",
115            "guides",
116        ];
117
118        // Check if the heading matches these patterns using whole-word matching
119        let words: Vec<&str> = lower_text.split_whitespace().collect();
120        section_indicators.iter().any(|&indicator| {
121            // Multi-word indicators need contiguous word matching
122            let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
123            let starts_with_indicator = if indicator_words.len() == 1 {
124                words.first() == Some(&indicator)
125            } else {
126                words.len() >= indicator_words.len()
127                    && words[..indicator_words.len()] == indicator_words[..]
128            };
129
130            starts_with_indicator ||
131            lower_text.starts_with(&format!("{indicator}:")) ||
132            // Whole-word match anywhere in the heading
133            words.contains(&indicator) ||
134            // Handle multi-word indicators appearing as a contiguous subsequence
135            (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
136            // Handle appendix numbering like "Appendix A", "Appendix 1"
137            (indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
138                let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
139                matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
140            })
141        })
142    }
143
144    /// Check if a line is a horizontal rule
145    fn is_horizontal_rule(line: &str) -> bool {
146        let trimmed = line.trim();
147        HR_DASH.is_match(trimmed)
148            || HR_ASTERISK.is_match(trimmed)
149            || HR_UNDERSCORE.is_match(trimmed)
150            || HR_SPACED_DASH.is_match(trimmed)
151            || HR_SPACED_ASTERISK.is_match(trimmed)
152            || HR_SPACED_UNDERSCORE.is_match(trimmed)
153    }
154
155    /// Check if a line might be a Setext heading underline
156    fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
157        if line_num == 0 || line_num >= ctx.lines.len() {
158            return false;
159        }
160
161        let line = ctx.lines[line_num].content(ctx.content).trim();
162        let prev_line = if line_num > 0 {
163            ctx.lines[line_num - 1].content(ctx.content).trim()
164        } else {
165            ""
166        };
167
168        let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
169        let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
170        let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
171        (is_dash_line || is_equals_line) && prev_line_has_content
172    }
173
174    /// Check if headings are separated by horizontal rules
175    fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
176        if !self.config.allow_with_separators || heading_line == 0 {
177            return false;
178        }
179
180        // Look for horizontal rules in the lines before this heading
181        // Check up to 5 lines before the heading for a horizontal rule
182        let search_start = heading_line.saturating_sub(5);
183
184        for line_num in search_start..heading_line {
185            if line_num >= ctx.lines.len() {
186                continue;
187            }
188
189            let line = &ctx.lines[line_num].content(ctx.content);
190            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
191                // Found a horizontal rule before this heading
192                // Check that there's no other heading between the HR and this heading
193                let has_intermediate_heading =
194                    ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
195
196                if !has_intermediate_heading {
197                    return true;
198                }
199            }
200        }
201
202        false
203    }
204}
205
206impl Rule for MD025SingleTitle {
207    fn name(&self) -> &'static str {
208        "MD025"
209    }
210
211    fn description(&self) -> &'static str {
212        "Multiple top-level headings in the same document"
213    }
214
215    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
216        // Early return for empty content
217        if ctx.lines.is_empty() {
218            return Ok(Vec::new());
219        }
220
221        let mut warnings = Vec::new();
222
223        let found_title_in_front_matter = self.has_front_matter_title(ctx);
224
225        // Find all headings at the target level using cached information
226        let mut target_level_headings = Vec::new();
227        for (line_num, line_info) in ctx.lines.iter().enumerate() {
228            if let Some(heading) = &line_info.heading
229                && heading.level as usize == self.config.level.as_usize()
230                && heading.is_valid
231            // Skip malformed headings like `#NoSpace`
232            {
233                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
234                if line_info.visual_indent >= 4 || line_info.in_code_block {
235                    continue;
236                }
237                target_level_headings.push(line_num);
238            }
239        }
240
241        // Determine which headings to flag as duplicates.
242        // If frontmatter has a title, it counts as the first heading,
243        // so ALL body headings at the target level are duplicates.
244        // Otherwise, skip the first body heading and flag the rest.
245        let headings_to_flag: &[usize] = if found_title_in_front_matter {
246            &target_level_headings
247        } else if target_level_headings.len() > 1 {
248            &target_level_headings[1..]
249        } else {
250            &[]
251        };
252
253        if !headings_to_flag.is_empty() {
254            for &line_num in headings_to_flag {
255                if let Some(heading) = &ctx.lines[line_num].heading {
256                    let heading_text = &heading.text;
257
258                    // Check if this heading should be allowed
259                    let should_allow = self.is_document_section_heading(heading_text)
260                        || self.has_separator_before_heading(ctx, line_num);
261
262                    if should_allow {
263                        continue; // Skip flagging this heading
264                    }
265
266                    // Calculate precise character range for the heading text content
267                    let line_content = &ctx.lines[line_num].content(ctx.content);
268                    let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
269                        pos
270                    } else {
271                        // Fallback: find after hash markers for ATX headings
272                        if line_content.trim_start().starts_with('#') {
273                            let trimmed = line_content.trim_start();
274                            let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
275                            let after_hashes = &trimmed[hash_count..];
276                            let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
277                            (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
278                        } else {
279                            0 // Setext headings start at beginning
280                        }
281                    };
282
283                    let (start_line, start_col, end_line, end_col) = calculate_match_range(
284                        line_num + 1, // Convert to 1-indexed
285                        line_content,
286                        text_start_in_line,
287                        heading_text.len(),
288                    );
289
290                    warnings.push(LintWarning {
291                        rule_name: Some(self.name().to_string()),
292                        message: format!(
293                            "Multiple top-level headings (level {}) in the same document",
294                            self.config.level.as_usize()
295                        ),
296                        line: start_line,
297                        column: start_col,
298                        end_line,
299                        end_column: end_col,
300                        severity: Severity::Error,
301                        fix: Some(Fix {
302                            range: ctx.line_index.line_content_range(line_num + 1),
303                            replacement: {
304                                let leading_spaces = line_content.len() - line_content.trim_start().len();
305                                let indentation = " ".repeat(leading_spaces);
306                                // Use raw_text to preserve inline attribute lists like { #id .class }
307                                let raw = &heading.raw_text;
308                                if raw.is_empty() {
309                                    format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
310                                } else {
311                                    format!(
312                                        "{}{} {}",
313                                        indentation,
314                                        "#".repeat(self.config.level.as_usize() + 1),
315                                        raw
316                                    )
317                                }
318                            },
319                        }),
320                    });
321                }
322            }
323        }
324
325        Ok(warnings)
326    }
327
328    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
329        let mut fixed_lines = Vec::new();
330        // If frontmatter has a title, treat it as the first heading at the target level,
331        // so all body headings at that level get demoted.
332        let mut found_first = self.has_front_matter_title(ctx);
333        let mut skip_next = false;
334
335        for (line_num, line_info) in ctx.lines.iter().enumerate() {
336            if skip_next {
337                skip_next = false;
338                continue;
339            }
340
341            if let Some(heading) = &line_info.heading {
342                if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
343                    if !found_first {
344                        found_first = true;
345                        // Keep the first heading as-is
346                        fixed_lines.push(line_info.content(ctx.content).to_string());
347
348                        // For Setext headings, also add the underline
349                        if matches!(
350                            heading.style,
351                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
352                        ) && line_num + 1 < ctx.lines.len()
353                        {
354                            fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
355                            skip_next = true;
356                        }
357                    } else {
358                        // Check if this heading should be allowed
359                        let should_allow = self.is_document_section_heading(&heading.text)
360                            || self.has_separator_before_heading(ctx, line_num);
361
362                        if should_allow {
363                            // Keep the heading as-is
364                            fixed_lines.push(line_info.content(ctx.content).to_string());
365
366                            // For Setext headings, also add the underline
367                            if matches!(
368                                heading.style,
369                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
370                            ) && line_num + 1 < ctx.lines.len()
371                            {
372                                fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
373                                skip_next = true;
374                            }
375                        } else {
376                            // Demote this heading to the next level
377                            let style = match heading.style {
378                                crate::lint_context::HeadingStyle::ATX => {
379                                    if heading.has_closing_sequence {
380                                        crate::rules::heading_utils::HeadingStyle::AtxClosed
381                                    } else {
382                                        crate::rules::heading_utils::HeadingStyle::Atx
383                                    }
384                                }
385                                crate::lint_context::HeadingStyle::Setext1 => {
386                                    // When demoting from level 1 to 2, use Setext2
387                                    if self.config.level.as_usize() == 1 {
388                                        crate::rules::heading_utils::HeadingStyle::Setext2
389                                    } else {
390                                        // For higher levels, use ATX
391                                        crate::rules::heading_utils::HeadingStyle::Atx
392                                    }
393                                }
394                                crate::lint_context::HeadingStyle::Setext2 => {
395                                    // Setext2 can only go to ATX
396                                    crate::rules::heading_utils::HeadingStyle::Atx
397                                }
398                            };
399
400                            let replacement = if heading.text.is_empty() {
401                                // For empty headings, manually construct the replacement
402                                match style {
403                                    crate::rules::heading_utils::HeadingStyle::Atx
404                                    | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
405                                        "#".repeat(self.config.level.as_usize() + 1)
406                                    }
407                                    crate::rules::heading_utils::HeadingStyle::AtxClosed
408                                    | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
409                                        format!(
410                                            "{} {}",
411                                            "#".repeat(self.config.level.as_usize() + 1),
412                                            "#".repeat(self.config.level.as_usize() + 1)
413                                        )
414                                    }
415                                    crate::rules::heading_utils::HeadingStyle::Setext1
416                                    | crate::rules::heading_utils::HeadingStyle::Setext2
417                                    | crate::rules::heading_utils::HeadingStyle::Consistent => {
418                                        // For empty Setext or Consistent, use ATX style
419                                        "#".repeat(self.config.level.as_usize() + 1)
420                                    }
421                                }
422                            } else {
423                                crate::rules::heading_utils::HeadingUtils::convert_heading_style(
424                                    &heading.raw_text,
425                                    (self.config.level.as_usize() + 1) as u32,
426                                    style,
427                                )
428                            };
429
430                            // Preserve original indentation (including tabs)
431                            let line = line_info.content(ctx.content);
432                            let original_indent = &line[..line_info.indent];
433                            fixed_lines.push(format!("{original_indent}{replacement}"));
434
435                            // For Setext headings, skip the original underline
436                            if matches!(
437                                heading.style,
438                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
439                            ) && line_num + 1 < ctx.lines.len()
440                            {
441                                skip_next = true;
442                            }
443                        }
444                    }
445                } else {
446                    // Not a target level heading, keep as-is
447                    fixed_lines.push(line_info.content(ctx.content).to_string());
448
449                    // For Setext headings, also add the underline
450                    if matches!(
451                        heading.style,
452                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
453                    ) && line_num + 1 < ctx.lines.len()
454                    {
455                        fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
456                        skip_next = true;
457                    }
458                }
459            } else {
460                // Not a heading line, keep as-is
461                fixed_lines.push(line_info.content(ctx.content).to_string());
462            }
463        }
464
465        let result = fixed_lines.join("\n");
466        if ctx.content.ends_with('\n') {
467            Ok(result + "\n")
468        } else {
469            Ok(result)
470        }
471    }
472
473    /// Get the category of this rule for selective processing
474    fn category(&self) -> RuleCategory {
475        RuleCategory::Heading
476    }
477
478    /// Check if this rule should be skipped for performance
479    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
480        // Skip if content is empty
481        if ctx.content.is_empty() {
482            return true;
483        }
484
485        // Skip if no heading markers at all
486        if !ctx.likely_has_headings() {
487            return true;
488        }
489
490        let has_fm_title = self.has_front_matter_title(ctx);
491
492        // Fast path: count target level headings efficiently
493        let mut target_level_count = 0;
494        for line_info in &ctx.lines {
495            if let Some(heading) = &line_info.heading
496                && heading.level as usize == self.config.level.as_usize()
497            {
498                // Ignore if indented 4+ spaces (indented code block), inside fenced code block, or PyMdown block
499                if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
500                    continue;
501                }
502                target_level_count += 1;
503
504                // If frontmatter has a title, even 1 body heading is a duplicate
505                if has_fm_title {
506                    return false;
507                }
508
509                // Otherwise, we need more than 1 to have duplicates
510                if target_level_count > 1 {
511                    return false;
512                }
513            }
514        }
515
516        // If we have 0 or 1 target level headings (without frontmatter title), skip
517        target_level_count <= 1
518    }
519
520    fn as_any(&self) -> &dyn std::any::Any {
521        self
522    }
523
524    fn default_config_section(&self) -> Option<(String, toml::Value)> {
525        let json_value = serde_json::to_value(&self.config).ok()?;
526        Some((
527            self.name().to_string(),
528            crate::rule_config_serde::json_to_toml_value(&json_value)?,
529        ))
530    }
531
532    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
533    where
534        Self: Sized,
535    {
536        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
537        Box::new(Self::from_config_struct(rule_config))
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_with_cached_headings() {
547        let rule = MD025SingleTitle::default();
548
549        // Test with only one level-1 heading
550        let content = "# Title\n\n## Section 1\n\n## Section 2";
551        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552        let result = rule.check(&ctx).unwrap();
553        assert!(result.is_empty());
554
555        // Test with multiple level-1 headings (non-section names) - should flag
556        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
557        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558        let result = rule.check(&ctx).unwrap();
559        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
560        assert_eq!(result[0].line, 5);
561
562        // Test with front matter title and a level-1 heading - should flag the body H1
563        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
564        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let result = rule.check(&ctx).unwrap();
566        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
567        assert_eq!(result[0].line, 5);
568    }
569
570    #[test]
571    fn test_allow_document_sections() {
572        // Need to create rule with allow_document_sections = true
573        let config = md025_config::MD025Config {
574            allow_document_sections: true,
575            ..Default::default()
576        };
577        let rule = MD025SingleTitle::from_config_struct(config);
578
579        // Test valid document sections that should NOT be flagged
580        let valid_cases = vec![
581            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
582            "# Introduction\n\nContent here\n\n# References\n\nRef content",
583            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
584            "# Manual\n\nContent\n\n# Index\n\nIndex content",
585            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
586            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
587            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
588        ];
589
590        for case in valid_cases {
591            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
592            let result = rule.check(&ctx).unwrap();
593            assert!(result.is_empty(), "Should not flag document sections in: {case}");
594        }
595
596        // Test invalid cases that should still be flagged
597        let invalid_cases = vec![
598            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
599            "# First\n\nContent\n\n# Second Title\n\nMore content",
600        ];
601
602        for case in invalid_cases {
603            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
604            let result = rule.check(&ctx).unwrap();
605            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
606        }
607    }
608
609    #[test]
610    fn test_strict_mode() {
611        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
612
613        // Even document sections should be flagged in strict mode
614        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
615        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616        let result = rule.check(&ctx).unwrap();
617        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
618    }
619
620    #[test]
621    fn test_bounds_checking_bug() {
622        // Test case that could trigger bounds error in fix generation
623        // When col + self.config.level.as_usize() exceeds line_content.len()
624        let rule = MD025SingleTitle::default();
625
626        // Create content with very short second heading
627        let content = "# First\n#";
628        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629
630        // This should not panic
631        let result = rule.check(&ctx);
632        assert!(result.is_ok());
633
634        // Test the fix as well
635        let fix_result = rule.fix(&ctx);
636        assert!(fix_result.is_ok());
637    }
638
639    #[test]
640    fn test_bounds_checking_edge_case() {
641        // Test case that specifically targets the bounds checking fix
642        // Create a heading where col + self.config.level.as_usize() would exceed line length
643        let rule = MD025SingleTitle::default();
644
645        // Create content where the second heading is just "#" (length 1)
646        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
647        // This should not exceed bounds for "#" but tests the edge case
648        let content = "# First Title\n#";
649        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650
651        // This should not panic and should handle the edge case gracefully
652        let result = rule.check(&ctx);
653        assert!(result.is_ok());
654
655        if let Ok(warnings) = result
656            && !warnings.is_empty()
657        {
658            // Check that the fix doesn't cause a panic
659            let fix_result = rule.fix(&ctx);
660            assert!(fix_result.is_ok());
661
662            // The fix should produce valid content
663            if let Ok(fixed_content) = fix_result {
664                assert!(!fixed_content.is_empty());
665                // Should convert the second "#" to "##" (or "## " if there's content)
666                assert!(fixed_content.contains("##"));
667            }
668        }
669    }
670
671    #[test]
672    fn test_horizontal_rule_separators() {
673        // Need to create rule with allow_with_separators = true
674        let config = md025_config::MD025Config {
675            allow_with_separators: true,
676            ..Default::default()
677        };
678        let rule = MD025SingleTitle::from_config_struct(config);
679
680        // Test that headings separated by horizontal rules are allowed
681        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.";
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 headings separated by horizontal rules"
687        );
688
689        // Test that headings without separators are still flagged
690        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.";
691        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692        let result = rule.check(&ctx).unwrap();
693        assert_eq!(result.len(), 1, "Should flag the heading without separator");
694        assert_eq!(result[0].line, 11); // Third title on line 11
695
696        // Test with allow_with_separators = false
697        let strict_rule = MD025SingleTitle::strict();
698        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
699        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = strict_rule.check(&ctx).unwrap();
701        assert_eq!(
702            result.len(),
703            1,
704            "Strict mode should flag all multiple H1s regardless of separators"
705        );
706    }
707
708    #[test]
709    fn test_python_comments_in_code_blocks() {
710        let rule = MD025SingleTitle::default();
711
712        // Test that Python comments in code blocks are not treated as headers
713        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.";
714        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715        let result = rule.check(&ctx).unwrap();
716        assert!(
717            result.is_empty(),
718            "Should not flag Python comments in code blocks as headings"
719        );
720
721        // Test the fix method doesn't modify Python comments
722        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
723        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724        let fixed = rule.fix(&ctx).unwrap();
725        assert!(
726            fixed.contains("# Python comment"),
727            "Fix should preserve Python comments in code blocks"
728        );
729        assert!(
730            fixed.contains("## Second Title"),
731            "Fix should demote the actual second heading"
732        );
733    }
734
735    #[test]
736    fn test_fix_preserves_attribute_lists() {
737        let rule = MD025SingleTitle::strict();
738
739        // Duplicate H1 with attribute list - fix should demote to H2 while preserving attrs
740        let content = "# First Title\n\n# Second Title { #custom-id .special }";
741        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742
743        // Should flag the second H1
744        let warnings = rule.check(&ctx).unwrap();
745        assert_eq!(warnings.len(), 1);
746        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
747        assert!(
748            fix.replacement.contains("{ #custom-id .special }"),
749            "check() fix should preserve attribute list, got: {}",
750            fix.replacement
751        );
752
753        // Verify fix() also preserves attribute list
754        let fixed = rule.fix(&ctx).unwrap();
755        assert!(
756            fixed.contains("## Second Title { #custom-id .special }"),
757            "fix() should demote to H2 while preserving attribute list, got: {fixed}"
758        );
759    }
760
761    #[test]
762    fn test_frontmatter_title_counts_as_h1() {
763        let rule = MD025SingleTitle::default();
764
765        // Frontmatter with title + one body H1 → should warn on the body H1
766        let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
767        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768        let result = rule.check(&ctx).unwrap();
769        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
770        assert_eq!(result[0].line, 5);
771    }
772
773    #[test]
774    fn test_frontmatter_title_with_multiple_body_h1s() {
775        let config = md025_config::MD025Config {
776            front_matter_title: "title".to_string(),
777            ..Default::default()
778        };
779        let rule = MD025SingleTitle::from_config_struct(config);
780
781        // Frontmatter with title + multiple body H1s → should warn on ALL body H1s
782        let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
783        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784        let result = rule.check(&ctx).unwrap();
785        assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
786        assert_eq!(result[0].line, 5);
787        assert_eq!(result[1].line, 9);
788    }
789
790    #[test]
791    fn test_frontmatter_without_title_no_warning() {
792        let rule = MD025SingleTitle::default();
793
794        // Frontmatter without title key + one body H1 → no warning
795        let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
796        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797        let result = rule.check(&ctx).unwrap();
798        assert!(result.is_empty(), "Should not flag when frontmatter has no title");
799    }
800
801    #[test]
802    fn test_no_frontmatter_single_h1_no_warning() {
803        let rule = MD025SingleTitle::default();
804
805        // No frontmatter + single body H1 → no warning
806        let content = "# Only Heading\n\nSome content.";
807        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808        let result = rule.check(&ctx).unwrap();
809        assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
810    }
811
812    #[test]
813    fn test_frontmatter_custom_title_key() {
814        // Custom front_matter_title key
815        let config = md025_config::MD025Config {
816            front_matter_title: "heading".to_string(),
817            ..Default::default()
818        };
819        let rule = MD025SingleTitle::from_config_struct(config);
820
821        // Frontmatter with "heading:" key → should count as H1
822        let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
823        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824        let result = rule.check(&ctx).unwrap();
825        assert_eq!(
826            result.len(),
827            1,
828            "Should flag body H1 when custom frontmatter key matches"
829        );
830        assert_eq!(result[0].line, 5);
831
832        // Frontmatter with "title:" but configured for "heading:" → should not count
833        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
834        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835        let result = rule.check(&ctx).unwrap();
836        assert!(
837            result.is_empty(),
838            "Should not flag when frontmatter key doesn't match config"
839        );
840    }
841
842    #[test]
843    fn test_frontmatter_title_empty_config_disables() {
844        // Empty front_matter_title disables frontmatter title detection
845        let rule = MD025SingleTitle::new(1, "");
846
847        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
848        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849        let result = rule.check(&ctx).unwrap();
850        assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
851    }
852
853    #[test]
854    fn test_frontmatter_title_with_level_config() {
855        // When level is set to 2, frontmatter title counts as the first heading at that level
856        let config = md025_config::MD025Config {
857            level: HeadingLevel::new(2).unwrap(),
858            front_matter_title: "title".to_string(),
859            ..Default::default()
860        };
861        let rule = MD025SingleTitle::from_config_struct(config);
862
863        // Frontmatter with title + body H2 → should flag body H2
864        let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
865        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867        assert_eq!(
868            result.len(),
869            1,
870            "Should flag body H2 when level=2 and frontmatter has title"
871        );
872        assert_eq!(result[0].line, 7);
873    }
874
875    #[test]
876    fn test_frontmatter_title_fix_demotes_body_heading() {
877        let config = md025_config::MD025Config {
878            front_matter_title: "title".to_string(),
879            ..Default::default()
880        };
881        let rule = MD025SingleTitle::from_config_struct(config);
882
883        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
884        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885        let fixed = rule.fix(&ctx).unwrap();
886        assert!(
887            fixed.contains("## Body Heading"),
888            "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
889        );
890        // Frontmatter should be preserved
891        assert!(fixed.contains("---\ntitle: FM Title\n---"));
892    }
893
894    #[test]
895    fn test_frontmatter_title_should_skip_respects_frontmatter() {
896        let rule = MD025SingleTitle::default();
897
898        // With frontmatter title + 1 body H1, should_skip should return false
899        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
900        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901        assert!(
902            !rule.should_skip(&ctx),
903            "should_skip must return false when frontmatter has title and body has H1"
904        );
905
906        // Without frontmatter title + 1 body H1, should_skip should return true
907        let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
908        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909        assert!(
910            rule.should_skip(&ctx),
911            "should_skip should return true with no frontmatter title and single H1"
912        );
913    }
914
915    #[test]
916    fn test_section_indicator_whole_word_matching() {
917        // Bug: substring matching causes false matches (e.g., "reindex" matches " index")
918        let config = md025_config::MD025Config {
919            allow_document_sections: true,
920            ..Default::default()
921        };
922        let rule = MD025SingleTitle::from_config_struct(config);
923
924        // These should NOT match section indicators (they contain indicators as substrings)
925        let false_positive_cases = vec![
926            "# Main Title\n\n# Understanding Reindex Operations",
927            "# Main Title\n\n# The Summarization Pipeline",
928            "# Main Title\n\n# Data Indexing Strategy",
929            "# Main Title\n\n# Unsupported Browsers",
930        ];
931
932        for case in false_positive_cases {
933            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
934            let result = rule.check(&ctx).unwrap();
935            assert_eq!(
936                result.len(),
937                1,
938                "Should flag duplicate H1 (not a section indicator): {case}"
939            );
940        }
941
942        // These SHOULD still match as legitimate section indicators
943        let true_positive_cases = vec![
944            "# Main Title\n\n# Index",
945            "# Main Title\n\n# Summary",
946            "# Main Title\n\n# About",
947            "# Main Title\n\n# References",
948        ];
949
950        for case in true_positive_cases {
951            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
952            let result = rule.check(&ctx).unwrap();
953            assert!(result.is_empty(), "Should allow section indicator heading: {case}");
954        }
955    }
956}