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    /// Check if a line might be a Setext heading underline
147    fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
148        if line_num == 0 || line_num >= ctx.lines.len() {
149            return false;
150        }
151
152        let line = ctx.lines[line_num].content(ctx.content).trim();
153        let prev_line = if line_num > 0 {
154            ctx.lines[line_num - 1].content(ctx.content).trim()
155        } else {
156            ""
157        };
158
159        let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
160        let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
161        let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
162        (is_dash_line || is_equals_line) && prev_line_has_content
163    }
164
165    /// The byte range one demoted ATX line replaces, and the indentation that
166    /// line keeps.
167    ///
168    /// A setext heading's text is the whole paragraph its underline ends, so the
169    /// span runs from the first of those lines through the underline and the
170    /// indentation is the first line's.
171    fn demotion_span(
172        ctx: &crate::lint_context::LintContext,
173        line_num: usize,
174        heading: &crate::lint_context::HeadingInfo,
175    ) -> (std::ops::Range<usize>, String) {
176        let first_idx = line_num + 1 - heading.text_lines;
177        let is_setext = matches!(
178            heading.style,
179            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
180        );
181        let range = if is_setext && line_num + 2 <= ctx.lines.len() {
182            ctx.line_content_byte_range(first_idx + 1).start..ctx.line_content_byte_range(line_num + 2).end
183        } else {
184            ctx.line_content_byte_range(first_idx + 1)
185        };
186        let first_content = ctx.lines[first_idx].content(ctx.content);
187        let leading_spaces = first_content.len() - first_content.trim_start().len();
188        (range, " ".repeat(leading_spaces))
189    }
190
191    /// Check if headings are separated by horizontal rules
192    fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
193        if !self.config.allow_with_separators || heading_line == 0 {
194            return false;
195        }
196
197        // Look for horizontal rules in the lines before this heading
198        // Check up to 5 lines before the heading for a horizontal rule
199        let search_start = heading_line.saturating_sub(5);
200
201        for line_num in search_start..heading_line {
202            if line_num >= ctx.lines.len() {
203                continue;
204            }
205
206            let line = &ctx.lines[line_num].content(ctx.content);
207            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
208                // Found a horizontal rule before this heading
209                // Check that there's no other heading between the HR and this heading
210                let has_intermediate_heading = ((line_num + 1)..heading_line).any(|idx| {
211                    idx < ctx.lines.len() && (ctx.lines[idx].heading.is_some() || ctx.lines[idx].is_setext_heading_text)
212                });
213
214                if !has_intermediate_heading {
215                    return true;
216                }
217            }
218        }
219
220        false
221    }
222}
223
224impl Rule for MD025SingleTitle {
225    fn name(&self) -> &'static str {
226        "MD025"
227    }
228
229    fn description(&self) -> &'static str {
230        "Multiple top-level headings in the same document"
231    }
232
233    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
234        // Early return for empty content
235        if ctx.lines.is_empty() {
236            return Ok(Vec::new());
237        }
238
239        let mut warnings = Vec::new();
240
241        let found_title_in_front_matter = self.has_front_matter_title(ctx);
242
243        // Find all headings at the target level using cached information
244        let mut target_level_headings = Vec::new();
245        for (line_num, line_info) in ctx.lines.iter().enumerate() {
246            if let Some(heading) = &line_info.heading
247                && heading.level as usize == self.config.level.as_usize()
248                && heading.is_valid
249            // Skip malformed headings like `#NoSpace`
250            {
251                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
252                if line_info.visual_indent >= 4 || line_info.in_code_block {
253                    continue;
254                }
255                target_level_headings.push(line_num);
256            }
257        }
258
259        // Determine which headings to flag as duplicates.
260        // If frontmatter has a title, it counts as the first heading,
261        // so ALL body headings at the target level are duplicates.
262        // Otherwise, skip the first body heading and flag the rest.
263        let headings_to_flag: &[usize] = if found_title_in_front_matter {
264            &target_level_headings
265        } else if target_level_headings.len() > 1 {
266            &target_level_headings[1..]
267        } else {
268            &[]
269        };
270
271        if !headings_to_flag.is_empty() {
272            for &line_num in headings_to_flag {
273                if let Some(heading) = &ctx.lines[line_num].heading {
274                    let heading_text = &heading.text;
275                    // A setext heading's text is the whole paragraph its underline
276                    // ends, so the heading starts on the first of those lines.
277                    let first_idx = line_num + 1 - heading.text_lines;
278
279                    // Check if this heading should be allowed
280                    let should_allow = self.is_document_section_heading(heading_text)
281                        || self.has_separator_before_heading(ctx, first_idx);
282
283                    if should_allow {
284                        continue; // Skip flagging this heading
285                    }
286
287                    // Calculate precise character range for the heading text content
288                    let line_content = &ctx.lines[line_num].content(ctx.content);
289                    let (start_line, start_col, end_line, end_col) = if heading.text_lines > 1 {
290                        // The warning starts at the text on the first line and
291                        // runs to the end of the text on the last.
292                        let first_content = ctx.lines[first_idx].content(ctx.content);
293                        let indent_chars = first_content.len() - first_content.trim_start().len();
294                        (
295                            first_idx + 1,
296                            first_content[..indent_chars].chars().count() + 1,
297                            line_num + 1,
298                            line_content.trim_end().chars().count() + 1,
299                        )
300                    } else {
301                        let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
302                            pos
303                        } else {
304                            // Fallback: find after hash markers for ATX headings
305                            if line_content.trim_start().starts_with('#') {
306                                let trimmed = line_content.trim_start();
307                                let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
308                                let after_hashes = &trimmed[hash_count..];
309                                let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
310                                (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
311                            } else {
312                                0 // Setext headings start at beginning
313                            }
314                        };
315                        calculate_match_range(
316                            line_num + 1, // Convert to 1-indexed
317                            line_content,
318                            text_start_in_line,
319                            heading_text.len(),
320                        )
321                    };
322
323                    let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
324
325                    // Demote to one level below the configured top-level heading.
326                    // Markdown only supports levels 1-6, so if the configured level
327                    // is already 6, the heading cannot be demoted.
328                    let demoted_level = self.config.level.as_usize() + 1;
329                    let fix = if demoted_level > 6 {
330                        None
331                    } else {
332                        let raw = &heading.raw_text;
333                        let hashes = "#".repeat(demoted_level);
334                        let closing = if heading.has_closing_sequence {
335                            format!(" {}", "#".repeat(demoted_level))
336                        } else {
337                            String::new()
338                        };
339                        let replacement = if raw.is_empty() {
340                            format!("{indentation}{hashes}{closing}")
341                        } else {
342                            format!("{indentation}{hashes} {raw}{closing}")
343                        };
344                        Some(Fix::new(fix_range, replacement))
345                    };
346
347                    warnings.push(LintWarning {
348                        rule_name: Some(self.name().to_string()),
349                        message: format!(
350                            "Multiple top-level headings (level {}) in the same document",
351                            self.config.level.as_usize()
352                        ),
353                        line: start_line,
354                        column: start_col,
355                        end_line,
356                        end_column: end_col,
357                        severity: Severity::Error,
358                        fix,
359                    });
360                }
361            }
362        }
363
364        Ok(warnings)
365    }
366
367    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
368        let warnings = self.check(ctx)?;
369        if warnings.is_empty() {
370            return Ok(ctx.content.to_string());
371        }
372        let warnings =
373            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
374
375        // Build the full fix set: each flagged heading plus every subordinate heading
376        // in its section, all demoted by the same +1 delta. Wrapping cascade fixes in
377        // synthetic LintWarning objects lets apply_warning_fixes handle range sorting
378        // and deduplication automatically.
379        let mut all_warnings = warnings.clone();
380
381        let target_level = self.config.level.as_usize();
382
383        for warning in &warnings {
384            // warning.line is 1-indexed and points at the heading's first text
385            // line; the heading itself is recorded on the last one, which is
386            // where the section below it starts.
387            let mut heading_line = warning.line - 1;
388            while heading_line + 1 < ctx.lines.len()
389                && ctx.lines[heading_line].heading.is_none()
390                && ctx.lines[heading_line].is_setext_heading_text
391            {
392                heading_line += 1;
393            }
394
395            // Section boundary: the next heading at or above target_level, or end of doc.
396            let section_end = ctx
397                .lines
398                .iter()
399                .enumerate()
400                .skip(heading_line + 1)
401                .find(|(_, li)| {
402                    li.heading.as_ref().is_some_and(|h| {
403                        h.level as usize <= target_level && h.is_valid && !li.in_code_block && li.visual_indent < 4
404                    })
405                })
406                .map_or(ctx.lines.len(), |(i, _)| i);
407
408            // Emit a cascade Fix for each subordinate heading inside [heading_line+1, section_end).
409            for line_num in (heading_line + 1)..section_end {
410                let line_info = &ctx.lines[line_num];
411                let Some(heading) = &line_info.heading else {
412                    continue;
413                };
414                if !heading.is_valid || line_info.in_code_block || line_info.visual_indent >= 4 {
415                    continue;
416                }
417
418                let new_level = heading.level as usize + 1;
419                if new_level > 6 {
420                    // Heading is already at the maximum depth; no fix possible.
421                    continue;
422                }
423
424                let line_content = line_info.content(ctx.content);
425
426                // For Setext headings the fix range must cover every text line and
427                // the underline so they are replaced atomically with one ATX line.
428                let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
429                let first_line = line_num + 2 - heading.text_lines;
430
431                let hashes = "#".repeat(new_level);
432                let raw = &heading.raw_text;
433                let closing = if heading.has_closing_sequence {
434                    format!(" {}", "#".repeat(new_level))
435                } else {
436                    String::new()
437                };
438                let replacement = if raw.is_empty() {
439                    format!("{indentation}{hashes}{closing}")
440                } else {
441                    format!("{indentation}{hashes} {raw}{closing}")
442                };
443
444                all_warnings.push(crate::rule::LintWarning {
445                    rule_name: Some(self.name().to_string()),
446                    message: String::new(),
447                    line: first_line,
448                    column: 1,
449                    end_line: line_num + 1,
450                    end_column: line_content.chars().count(),
451                    severity: crate::rule::Severity::Error,
452                    fix: Some(Fix::new(fix_range, replacement)),
453                });
454            }
455        }
456
457        // Filter cascade warnings through the same inline-disable logic applied to the
458        // original warnings. This ensures that a subordinate heading on a disabled line
459        // (e.g., `<!-- markdownlint-disable-line MD025 -->`) is not cascade-demoted.
460        let all_warnings =
461            crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
462
463        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
464            .map_err(crate::rule::LintError::InvalidInput)
465    }
466
467    /// Get the category of this rule for selective processing
468    fn category(&self) -> RuleCategory {
469        RuleCategory::Heading
470    }
471
472    /// Check if this rule should be skipped for performance
473    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
474        // Skip if content is empty
475        if ctx.content.is_empty() {
476            return true;
477        }
478
479        // Skip if no heading markers at all
480        if !ctx.likely_has_headings() {
481            return true;
482        }
483
484        let has_fm_title = self.has_front_matter_title(ctx);
485
486        // Fast path: count target level headings efficiently
487        let mut target_level_count = 0;
488        for line_info in &ctx.lines {
489            if let Some(heading) = &line_info.heading
490                && heading.level as usize == self.config.level.as_usize()
491            {
492                // Ignore if indented 4+ spaces (indented code block), inside fenced code block, or PyMdown block
493                if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
494                    continue;
495                }
496                target_level_count += 1;
497
498                // If frontmatter has a title, even 1 body heading is a duplicate
499                if has_fm_title {
500                    return false;
501                }
502
503                // Otherwise, we need more than 1 to have duplicates
504                if target_level_count > 1 {
505                    return false;
506                }
507            }
508        }
509
510        // If we have 0 or 1 target level headings (without frontmatter title), skip
511        target_level_count <= 1
512    }
513
514    fn as_any(&self) -> &dyn std::any::Any {
515        self
516    }
517
518    crate::impl_rule_config_methods!(MD025Config);
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_with_cached_headings() {
527        let rule = MD025SingleTitle::default();
528
529        // Test with only one level-1 heading
530        let content = "# Title\n\n## Section 1\n\n## Section 2";
531        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533        assert!(result.is_empty());
534
535        // Test with multiple level-1 headings (non-section names) - should flag
536        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
537        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538        let result = rule.check(&ctx).unwrap();
539        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
540        assert_eq!(result[0].line, 5);
541
542        // Test with front matter title and a level-1 heading - should flag the body H1
543        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
544        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545        let result = rule.check(&ctx).unwrap();
546        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
547        assert_eq!(result[0].line, 5);
548    }
549
550    #[test]
551    fn test_allow_document_sections() {
552        // Need to create rule with allow_document_sections = true
553        let config = md025_config::MD025Config {
554            allow_document_sections: true,
555            ..Default::default()
556        };
557        let rule = MD025SingleTitle::from_config_struct(config);
558
559        // Test valid document sections that should NOT be flagged
560        let valid_cases = vec![
561            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
562            "# Introduction\n\nContent here\n\n# References\n\nRef content",
563            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
564            "# Manual\n\nContent\n\n# Index\n\nIndex content",
565            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
566            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
567            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
568        ];
569
570        for case in valid_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 not flag document sections in: {case}");
574        }
575
576        // Test invalid cases that should still be flagged
577        let invalid_cases = vec![
578            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
579            "# First\n\nContent\n\n# Second Title\n\nMore content",
580        ];
581
582        for case in invalid_cases {
583            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
584            let result = rule.check(&ctx).unwrap();
585            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
586        }
587    }
588
589    #[test]
590    fn test_strict_mode() {
591        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
592
593        // Even document sections should be flagged in strict mode
594        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
595        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let result = rule.check(&ctx).unwrap();
597        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
598    }
599
600    #[test]
601    fn test_bounds_checking_bug() {
602        // Test case that could trigger bounds error in fix generation
603        // When col + self.config.level.as_usize() exceeds line_content.len()
604        let rule = MD025SingleTitle::default();
605
606        // Create content with very short second heading
607        let content = "# First\n#";
608        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609
610        // This should not panic
611        let result = rule.check(&ctx);
612        assert!(result.is_ok());
613
614        // Test the fix as well
615        let fix_result = rule.fix(&ctx);
616        assert!(fix_result.is_ok());
617    }
618
619    #[test]
620    fn test_bounds_checking_edge_case() {
621        // Test case that specifically targets the bounds checking fix
622        // Create a heading where col + self.config.level.as_usize() would exceed line length
623        let rule = MD025SingleTitle::default();
624
625        // Create content where the second heading is just "#" (length 1)
626        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
627        // This should not exceed bounds for "#" but tests the edge case
628        let content = "# First Title\n#";
629        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630
631        // This should not panic and should handle the edge case gracefully
632        let result = rule.check(&ctx);
633        assert!(result.is_ok());
634
635        if let Ok(warnings) = result
636            && !warnings.is_empty()
637        {
638            // Check that the fix doesn't cause a panic
639            let fix_result = rule.fix(&ctx);
640            assert!(fix_result.is_ok());
641
642            // The fix should produce valid content
643            if let Ok(fixed_content) = fix_result {
644                assert!(!fixed_content.is_empty());
645                // Should convert the second "#" to "##" (or "## " if there's content)
646                assert!(fixed_content.contains("##"));
647            }
648        }
649    }
650
651    #[test]
652    fn test_horizontal_rule_separators() {
653        // Need to create rule with allow_with_separators = true
654        let config = md025_config::MD025Config {
655            allow_with_separators: true,
656            ..Default::default()
657        };
658        let rule = MD025SingleTitle::from_config_struct(config);
659
660        // Test that headings separated by horizontal rules are allowed
661        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.";
662        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663        let result = rule.check(&ctx).unwrap();
664        assert!(
665            result.is_empty(),
666            "Should not flag headings separated by horizontal rules"
667        );
668
669        // Test that headings without separators are still flagged
670        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.";
671        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672        let result = rule.check(&ctx).unwrap();
673        assert_eq!(result.len(), 1, "Should flag the heading without separator");
674        assert_eq!(result[0].line, 11); // Third title on line 11
675
676        // Test with allow_with_separators = false
677        let strict_rule = MD025SingleTitle::strict();
678        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
679        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let result = strict_rule.check(&ctx).unwrap();
681        assert_eq!(
682            result.len(),
683            1,
684            "Strict mode should flag all multiple H1s regardless of separators"
685        );
686    }
687
688    #[test]
689    fn test_python_comments_in_code_blocks() {
690        let rule = MD025SingleTitle::default();
691
692        // Test that Python comments in code blocks are not treated as headers
693        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.";
694        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695        let result = rule.check(&ctx).unwrap();
696        assert!(
697            result.is_empty(),
698            "Should not flag Python comments in code blocks as headings"
699        );
700
701        // Test the fix method doesn't modify Python comments
702        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
703        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704        let fixed = rule.fix(&ctx).unwrap();
705        assert!(
706            fixed.contains("# Python comment"),
707            "Fix should preserve Python comments in code blocks"
708        );
709        assert!(
710            fixed.contains("## Second Title"),
711            "Fix should demote the actual second heading"
712        );
713    }
714
715    #[test]
716    fn test_fix_preserves_attribute_lists() {
717        let rule = MD025SingleTitle::strict();
718
719        // Duplicate H1 with attribute list - fix should demote to H2 while preserving attrs
720        let content = "# First Title\n\n# Second Title { #custom-id .special }";
721        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722
723        // Should flag the second H1
724        let warnings = rule.check(&ctx).unwrap();
725        assert_eq!(warnings.len(), 1);
726        // Per-warning fix demotes the heading itself (cascade is handled by fix() method)
727        assert!(warnings[0].fix.is_some());
728
729        // Verify fix() preserves attribute list
730        let fixed = rule.fix(&ctx).unwrap();
731        assert!(
732            fixed.contains("## Second Title { #custom-id .special }"),
733            "fix() should demote to H2 while preserving attribute list, got: {fixed}"
734        );
735    }
736
737    #[test]
738    fn test_frontmatter_title_counts_as_h1() {
739        let rule = MD025SingleTitle::default();
740
741        // Frontmatter with title + one body H1 → should warn on the body H1
742        let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
743        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let result = rule.check(&ctx).unwrap();
745        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
746        assert_eq!(result[0].line, 5);
747    }
748
749    #[test]
750    fn test_frontmatter_title_with_multiple_body_h1s() {
751        let config = md025_config::MD025Config {
752            front_matter_title: "title".to_string(),
753            ..Default::default()
754        };
755        let rule = MD025SingleTitle::from_config_struct(config);
756
757        // Frontmatter with title + multiple body H1s → should warn on ALL body H1s
758        let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
759        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760        let result = rule.check(&ctx).unwrap();
761        assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
762        assert_eq!(result[0].line, 5);
763        assert_eq!(result[1].line, 9);
764    }
765
766    #[test]
767    fn test_frontmatter_without_title_no_warning() {
768        let rule = MD025SingleTitle::default();
769
770        // Frontmatter without title key + one body H1 → no warning
771        let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
772        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.check(&ctx).unwrap();
774        assert!(result.is_empty(), "Should not flag when frontmatter has no title");
775    }
776
777    #[test]
778    fn test_no_frontmatter_single_h1_no_warning() {
779        let rule = MD025SingleTitle::default();
780
781        // No frontmatter + single body H1 → no warning
782        let content = "# Only Heading\n\nSome content.";
783        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784        let result = rule.check(&ctx).unwrap();
785        assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
786    }
787
788    #[test]
789    fn test_frontmatter_custom_title_key() {
790        // Custom front_matter_title key
791        let config = md025_config::MD025Config {
792            front_matter_title: "heading".to_string(),
793            ..Default::default()
794        };
795        let rule = MD025SingleTitle::from_config_struct(config);
796
797        // Frontmatter with "heading:" key → should count as H1
798        let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
799        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let result = rule.check(&ctx).unwrap();
801        assert_eq!(
802            result.len(),
803            1,
804            "Should flag body H1 when custom frontmatter key matches"
805        );
806        assert_eq!(result[0].line, 5);
807
808        // Frontmatter with "title:" but configured for "heading:" → should not count
809        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
810        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811        let result = rule.check(&ctx).unwrap();
812        assert!(
813            result.is_empty(),
814            "Should not flag when frontmatter key doesn't match config"
815        );
816    }
817
818    #[test]
819    fn test_frontmatter_title_empty_config_disables() {
820        // Empty front_matter_title disables frontmatter title detection
821        let rule = MD025SingleTitle::new(1, "");
822
823        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
824        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825        let result = rule.check(&ctx).unwrap();
826        assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
827    }
828
829    #[test]
830    fn test_frontmatter_title_with_level_config() {
831        // When level is set to 2, frontmatter title counts as the first heading at that level
832        let config = md025_config::MD025Config {
833            level: HeadingLevel::new(2).unwrap(),
834            front_matter_title: "title".to_string(),
835            ..Default::default()
836        };
837        let rule = MD025SingleTitle::from_config_struct(config);
838
839        // Frontmatter with title + body H2 → should flag body H2
840        let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
841        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842        let result = rule.check(&ctx).unwrap();
843        assert_eq!(
844            result.len(),
845            1,
846            "Should flag body H2 when level=2 and frontmatter has title"
847        );
848        assert_eq!(result[0].line, 7);
849    }
850
851    #[test]
852    fn test_frontmatter_title_fix_demotes_body_heading() {
853        let config = md025_config::MD025Config {
854            front_matter_title: "title".to_string(),
855            ..Default::default()
856        };
857        let rule = MD025SingleTitle::from_config_struct(config);
858
859        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
860        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861        let fixed = rule.fix(&ctx).unwrap();
862        assert!(
863            fixed.contains("## Body Heading"),
864            "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
865        );
866        // Frontmatter should be preserved
867        assert!(fixed.contains("---\ntitle: FM Title\n---"));
868    }
869
870    #[test]
871    fn test_frontmatter_title_should_skip_respects_frontmatter() {
872        let rule = MD025SingleTitle::default();
873
874        // With frontmatter title + 1 body H1, should_skip should return false
875        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
876        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877        assert!(
878            !rule.should_skip(&ctx),
879            "should_skip must return false when frontmatter has title and body has H1"
880        );
881
882        // Without frontmatter title + 1 body H1, should_skip should return true
883        let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
884        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885        assert!(
886            rule.should_skip(&ctx),
887            "should_skip should return true with no frontmatter title and single H1"
888        );
889    }
890
891    #[test]
892    fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
893        let rule = MD025SingleTitle::default();
894
895        // Exact reproduction from issue #573
896        let content = "abcd\n\n# 1_1\n\n# 1_2\n\n## 1_2-2_1\n\n# 1_3\n\n## 1_3-2_1\n\n### 1_3-2_1-3_1\n";
897        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let fixed = rule.fix(&ctx).unwrap();
899
900        assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
901        assert!(
902            fixed.contains("## 1_2\n"),
903            "Duplicate H1 must be demoted to H2: {fixed}"
904        );
905        assert!(
906            fixed.contains("### 1_2-2_1"),
907            "H2 under demoted H1 must cascade to H3: {fixed}"
908        );
909        assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
910        assert!(
911            fixed.contains("### 1_3-2_1"),
912            "H2 under third demoted H1 must cascade to H3: {fixed}"
913        );
914        assert!(
915            fixed.contains("#### 1_3-2_1-3_1"),
916            "H3 under third demoted H1 must cascade to H4: {fixed}"
917        );
918    }
919
920    #[test]
921    fn test_fix_cascades_single_section_only() {
922        let rule = MD025SingleTitle::default();
923
924        // Sub-headings of a demoted section must not affect sub-headings of other sections
925        let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
926        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927        let fixed = rule.fix(&ctx).unwrap();
928
929        assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
930        assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
931        assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
932        assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
933        assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
934    }
935
936    #[test]
937    fn test_fix_cascade_stops_at_next_same_level() {
938        let rule = MD025SingleTitle::default();
939
940        // H2 under first demoted section must not bleed into content after the next H1
941        // (which is itself demoted). The cascade boundary is the next heading at or above
942        // the original target level.
943        let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
944        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945        let fixed = rule.fix(&ctx).unwrap();
946
947        assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
948        assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
949        assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
950        assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
951        assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
952        // Original first H1 still at level 1
953        assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
954    }
955
956    #[test]
957    fn test_fix_cascade_does_not_exceed_level_6() {
958        // A heading at level 6 under a demoted section cannot go deeper; it stays at 6.
959        let rule = MD025SingleTitle::default();
960
961        // Build a chain: H1, H1, H2, H3, H4, H5, H6 under the second H1
962        let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
963        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964        let fixed = rule.fix(&ctx).unwrap();
965
966        assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
967        assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
968        assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
969        assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
970        assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
971        assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
972        // L6 cannot go to H7 — stays at H6
973        assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
974    }
975
976    #[test]
977    fn test_fix_cascade_respects_inline_disable_on_subordinate() {
978        // A subordinate heading on a markdownlint-disable-line MD025 line must not
979        // be cascade-fixed: the inline disable explicitly opts that line out.
980        let rule = MD025SingleTitle::default();
981
982        let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
983        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let fixed = rule.fix(&ctx).unwrap();
985
986        assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
987        // ## Skip has an inline disable — cascade must not touch it.
988        // Use exact-prefix matching to avoid "## Skip" matching inside "### Skip".
989        let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
990        assert!(
991            skip_line.starts_with("## Skip"),
992            "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
993        );
994        // ## Cascade has no disable — it falls in the section and must cascade
995        assert!(
996            fixed.contains("### Cascade"),
997            "Non-disabled subordinate should cascade to level 3: {fixed}"
998        );
999    }
1000
1001    #[test]
1002    fn test_section_indicator_whole_word_matching() {
1003        // Bug: substring matching causes false matches (e.g., "reindex" matches " index")
1004        let config = md025_config::MD025Config {
1005            allow_document_sections: true,
1006            ..Default::default()
1007        };
1008        let rule = MD025SingleTitle::from_config_struct(config);
1009
1010        // These should NOT match section indicators (they contain indicators as substrings)
1011        let false_positive_cases = vec![
1012            "# Main Title\n\n# Understanding Reindex Operations",
1013            "# Main Title\n\n# The Summarization Pipeline",
1014            "# Main Title\n\n# Data Indexing Strategy",
1015            "# Main Title\n\n# Unsupported Browsers",
1016        ];
1017
1018        for case in false_positive_cases {
1019            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1020            let result = rule.check(&ctx).unwrap();
1021            assert_eq!(
1022                result.len(),
1023                1,
1024                "Should flag duplicate H1 (not a section indicator): {case}"
1025            );
1026        }
1027
1028        // These SHOULD still match as legitimate section indicators
1029        let true_positive_cases = vec![
1030            "# Main Title\n\n# Index",
1031            "# Main Title\n\n# Summary",
1032            "# Main Title\n\n# About",
1033            "# Main Title\n\n# References",
1034        ];
1035
1036        for case in true_positive_cases {
1037            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1038            let result = rule.check(&ctx).unwrap();
1039            assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1040        }
1041    }
1042
1043    #[test]
1044    fn test_mdg_enforces_single_title() {
1045        // Heading levels carry no meaning in the Gherkin AST, so demoting an
1046        // extra H1 keeps the document valid and MD025 stays enforced.
1047        let rule = MD025SingleTitle::strict();
1048        let content = "# Feature: Checkout\n\n# Rule: Registered customers\n\n# Scenario: Purchase\n";
1049
1050        let standard_ctx =
1051            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052        let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1053
1054        assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 2);
1055        assert_eq!(
1056            rule.check(&mdg_ctx).unwrap().len(),
1057            rule.check(&standard_ctx).unwrap().len(),
1058            "MDG must not differ from Standard"
1059        );
1060
1061        let fixed = rule.fix(&mdg_ctx).unwrap();
1062        assert_eq!(
1063            fixed, "# Feature: Checkout\n\n## Rule: Registered customers\n\n## Scenario: Purchase\n",
1064            "the Gherkin keywords must survive the demotion"
1065        );
1066
1067        let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1068        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
1069    }
1070}