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