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