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