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