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    fn default_config_section(&self) -> Option<(String, toml::Value)> {
495        let json_value = serde_json::to_value(&self.config).ok()?;
496        Some((
497            self.name().to_string(),
498            crate::rule_config_serde::json_to_toml_value(&json_value)?,
499        ))
500    }
501
502    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
503    where
504        Self: Sized,
505    {
506        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
507        Box::new(Self::from_config_struct(rule_config))
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_with_cached_headings() {
517        let rule = MD025SingleTitle::default();
518
519        // Test with only one level-1 heading
520        let content = "# Title\n\n## Section 1\n\n## Section 2";
521        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522        let result = rule.check(&ctx).unwrap();
523        assert!(result.is_empty());
524
525        // Test with multiple level-1 headings (non-section names) - should flag
526        let content = "# Title 1\n\n## Section 1\n\n# Another Title\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_eq!(result.len(), 1); // Should flag the second level-1 heading
530        assert_eq!(result[0].line, 5);
531
532        // Test with front matter title and a level-1 heading - should flag the body H1
533        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
534        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535        let result = rule.check(&ctx).unwrap();
536        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
537        assert_eq!(result[0].line, 5);
538    }
539
540    #[test]
541    fn test_allow_document_sections() {
542        // Need to create rule with allow_document_sections = true
543        let config = md025_config::MD025Config {
544            allow_document_sections: true,
545            ..Default::default()
546        };
547        let rule = MD025SingleTitle::from_config_struct(config);
548
549        // Test valid document sections that should NOT be flagged
550        let valid_cases = vec![
551            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
552            "# Introduction\n\nContent here\n\n# References\n\nRef content",
553            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
554            "# Manual\n\nContent\n\n# Index\n\nIndex content",
555            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
556            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
557            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
558        ];
559
560        for case in valid_cases {
561            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
562            let result = rule.check(&ctx).unwrap();
563            assert!(result.is_empty(), "Should not flag document sections in: {case}");
564        }
565
566        // Test invalid cases that should still be flagged
567        let invalid_cases = vec![
568            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
569            "# First\n\nContent\n\n# Second Title\n\nMore content",
570        ];
571
572        for case in invalid_cases {
573            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
574            let result = rule.check(&ctx).unwrap();
575            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
576        }
577    }
578
579    #[test]
580    fn test_strict_mode() {
581        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
582
583        // Even document sections should be flagged in strict mode
584        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
585        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586        let result = rule.check(&ctx).unwrap();
587        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
588    }
589
590    #[test]
591    fn test_bounds_checking_bug() {
592        // Test case that could trigger bounds error in fix generation
593        // When col + self.config.level.as_usize() exceeds line_content.len()
594        let rule = MD025SingleTitle::default();
595
596        // Create content with very short second heading
597        let content = "# First\n#";
598        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599
600        // This should not panic
601        let result = rule.check(&ctx);
602        assert!(result.is_ok());
603
604        // Test the fix as well
605        let fix_result = rule.fix(&ctx);
606        assert!(fix_result.is_ok());
607    }
608
609    #[test]
610    fn test_bounds_checking_edge_case() {
611        // Test case that specifically targets the bounds checking fix
612        // Create a heading where col + self.config.level.as_usize() would exceed line length
613        let rule = MD025SingleTitle::default();
614
615        // Create content where the second heading is just "#" (length 1)
616        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
617        // This should not exceed bounds for "#" but tests the edge case
618        let content = "# First Title\n#";
619        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620
621        // This should not panic and should handle the edge case gracefully
622        let result = rule.check(&ctx);
623        assert!(result.is_ok());
624
625        if let Ok(warnings) = result
626            && !warnings.is_empty()
627        {
628            // Check that the fix doesn't cause a panic
629            let fix_result = rule.fix(&ctx);
630            assert!(fix_result.is_ok());
631
632            // The fix should produce valid content
633            if let Ok(fixed_content) = fix_result {
634                assert!(!fixed_content.is_empty());
635                // Should convert the second "#" to "##" (or "## " if there's content)
636                assert!(fixed_content.contains("##"));
637            }
638        }
639    }
640
641    #[test]
642    fn test_horizontal_rule_separators() {
643        // Need to create rule with allow_with_separators = true
644        let config = md025_config::MD025Config {
645            allow_with_separators: true,
646            ..Default::default()
647        };
648        let rule = MD025SingleTitle::from_config_struct(config);
649
650        // Test that headings separated by horizontal rules are allowed
651        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.";
652        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653        let result = rule.check(&ctx).unwrap();
654        assert!(
655            result.is_empty(),
656            "Should not flag headings separated by horizontal rules"
657        );
658
659        // Test that headings without separators are still flagged
660        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.";
661        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662        let result = rule.check(&ctx).unwrap();
663        assert_eq!(result.len(), 1, "Should flag the heading without separator");
664        assert_eq!(result[0].line, 11); // Third title on line 11
665
666        // Test with allow_with_separators = false
667        let strict_rule = MD025SingleTitle::strict();
668        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
669        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670        let result = strict_rule.check(&ctx).unwrap();
671        assert_eq!(
672            result.len(),
673            1,
674            "Strict mode should flag all multiple H1s regardless of separators"
675        );
676    }
677
678    #[test]
679    fn test_python_comments_in_code_blocks() {
680        let rule = MD025SingleTitle::default();
681
682        // Test that Python comments in code blocks are not treated as headers
683        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.";
684        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685        let result = rule.check(&ctx).unwrap();
686        assert!(
687            result.is_empty(),
688            "Should not flag Python comments in code blocks as headings"
689        );
690
691        // Test the fix method doesn't modify Python comments
692        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
693        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let fixed = rule.fix(&ctx).unwrap();
695        assert!(
696            fixed.contains("# Python comment"),
697            "Fix should preserve Python comments in code blocks"
698        );
699        assert!(
700            fixed.contains("## Second Title"),
701            "Fix should demote the actual second heading"
702        );
703    }
704
705    #[test]
706    fn test_fix_preserves_attribute_lists() {
707        let rule = MD025SingleTitle::strict();
708
709        // Duplicate H1 with attribute list - fix should demote to H2 while preserving attrs
710        let content = "# First Title\n\n# Second Title { #custom-id .special }";
711        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712
713        // Should flag the second H1
714        let warnings = rule.check(&ctx).unwrap();
715        assert_eq!(warnings.len(), 1);
716        // Per-warning fix demotes the heading itself (cascade is handled by fix() method)
717        assert!(warnings[0].fix.is_some());
718
719        // Verify fix() preserves attribute list
720        let fixed = rule.fix(&ctx).unwrap();
721        assert!(
722            fixed.contains("## Second Title { #custom-id .special }"),
723            "fix() should demote to H2 while preserving attribute list, got: {fixed}"
724        );
725    }
726
727    #[test]
728    fn test_frontmatter_title_counts_as_h1() {
729        let rule = MD025SingleTitle::default();
730
731        // Frontmatter with title + one body H1 → should warn on the body H1
732        let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
733        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
734        let result = rule.check(&ctx).unwrap();
735        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
736        assert_eq!(result[0].line, 5);
737    }
738
739    #[test]
740    fn test_frontmatter_title_with_multiple_body_h1s() {
741        let config = md025_config::MD025Config {
742            front_matter_title: "title".to_string(),
743            ..Default::default()
744        };
745        let rule = MD025SingleTitle::from_config_struct(config);
746
747        // Frontmatter with title + multiple body H1s → should warn on ALL body H1s
748        let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
749        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750        let result = rule.check(&ctx).unwrap();
751        assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
752        assert_eq!(result[0].line, 5);
753        assert_eq!(result[1].line, 9);
754    }
755
756    #[test]
757    fn test_frontmatter_without_title_no_warning() {
758        let rule = MD025SingleTitle::default();
759
760        // Frontmatter without title key + one body H1 → no warning
761        let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
762        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let result = rule.check(&ctx).unwrap();
764        assert!(result.is_empty(), "Should not flag when frontmatter has no title");
765    }
766
767    #[test]
768    fn test_no_frontmatter_single_h1_no_warning() {
769        let rule = MD025SingleTitle::default();
770
771        // No frontmatter + single body H1 → no warning
772        let content = "# Only Heading\n\nSome content.";
773        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774        let result = rule.check(&ctx).unwrap();
775        assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
776    }
777
778    #[test]
779    fn test_frontmatter_custom_title_key() {
780        // Custom front_matter_title key
781        let config = md025_config::MD025Config {
782            front_matter_title: "heading".to_string(),
783            ..Default::default()
784        };
785        let rule = MD025SingleTitle::from_config_struct(config);
786
787        // Frontmatter with "heading:" key → should count as H1
788        let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
789        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791        assert_eq!(
792            result.len(),
793            1,
794            "Should flag body H1 when custom frontmatter key matches"
795        );
796        assert_eq!(result[0].line, 5);
797
798        // Frontmatter with "title:" but configured for "heading:" → should not count
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!(
803            result.is_empty(),
804            "Should not flag when frontmatter key doesn't match config"
805        );
806    }
807
808    #[test]
809    fn test_frontmatter_title_empty_config_disables() {
810        // Empty front_matter_title disables frontmatter title detection
811        let rule = MD025SingleTitle::new(1, "");
812
813        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
814        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815        let result = rule.check(&ctx).unwrap();
816        assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
817    }
818
819    #[test]
820    fn test_frontmatter_title_with_level_config() {
821        // When level is set to 2, frontmatter title counts as the first heading at that level
822        let config = md025_config::MD025Config {
823            level: HeadingLevel::new(2).unwrap(),
824            front_matter_title: "title".to_string(),
825            ..Default::default()
826        };
827        let rule = MD025SingleTitle::from_config_struct(config);
828
829        // Frontmatter with title + body H2 → should flag body H2
830        let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
831        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832        let result = rule.check(&ctx).unwrap();
833        assert_eq!(
834            result.len(),
835            1,
836            "Should flag body H2 when level=2 and frontmatter has title"
837        );
838        assert_eq!(result[0].line, 7);
839    }
840
841    #[test]
842    fn test_frontmatter_title_fix_demotes_body_heading() {
843        let config = md025_config::MD025Config {
844            front_matter_title: "title".to_string(),
845            ..Default::default()
846        };
847        let rule = MD025SingleTitle::from_config_struct(config);
848
849        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
850        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851        let fixed = rule.fix(&ctx).unwrap();
852        assert!(
853            fixed.contains("## Body Heading"),
854            "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
855        );
856        // Frontmatter should be preserved
857        assert!(fixed.contains("---\ntitle: FM Title\n---"));
858    }
859
860    #[test]
861    fn test_frontmatter_title_should_skip_respects_frontmatter() {
862        let rule = MD025SingleTitle::default();
863
864        // With frontmatter title + 1 body H1, should_skip should return false
865        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
866        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867        assert!(
868            !rule.should_skip(&ctx),
869            "should_skip must return false when frontmatter has title and body has H1"
870        );
871
872        // Without frontmatter title + 1 body H1, should_skip should return true
873        let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
874        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875        assert!(
876            rule.should_skip(&ctx),
877            "should_skip should return true with no frontmatter title and single H1"
878        );
879    }
880
881    #[test]
882    fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
883        let rule = MD025SingleTitle::default();
884
885        // Exact reproduction from issue #573
886        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";
887        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let fixed = rule.fix(&ctx).unwrap();
889
890        assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
891        assert!(
892            fixed.contains("## 1_2\n"),
893            "Duplicate H1 must be demoted to H2: {fixed}"
894        );
895        assert!(
896            fixed.contains("### 1_2-2_1"),
897            "H2 under demoted H1 must cascade to H3: {fixed}"
898        );
899        assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
900        assert!(
901            fixed.contains("### 1_3-2_1"),
902            "H2 under third demoted H1 must cascade to H3: {fixed}"
903        );
904        assert!(
905            fixed.contains("#### 1_3-2_1-3_1"),
906            "H3 under third demoted H1 must cascade to H4: {fixed}"
907        );
908    }
909
910    #[test]
911    fn test_fix_cascades_single_section_only() {
912        let rule = MD025SingleTitle::default();
913
914        // Sub-headings of a demoted section must not affect sub-headings of other sections
915        let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
916        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917        let fixed = rule.fix(&ctx).unwrap();
918
919        assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
920        assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
921        assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
922        assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
923        assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
924    }
925
926    #[test]
927    fn test_fix_cascade_stops_at_next_same_level() {
928        let rule = MD025SingleTitle::default();
929
930        // H2 under first demoted section must not bleed into content after the next H1
931        // (which is itself demoted). The cascade boundary is the next heading at or above
932        // the original target level.
933        let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
934        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935        let fixed = rule.fix(&ctx).unwrap();
936
937        assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
938        assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
939        assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
940        assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
941        assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
942        // Original first H1 still at level 1
943        assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
944    }
945
946    #[test]
947    fn test_fix_cascade_does_not_exceed_level_6() {
948        // A heading at level 6 under a demoted section cannot go deeper; it stays at 6.
949        let rule = MD025SingleTitle::default();
950
951        // Build a chain: H1, H1, H2, H3, H4, H5, H6 under the second H1
952        let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
953        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954        let fixed = rule.fix(&ctx).unwrap();
955
956        assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
957        assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
958        assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
959        assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
960        assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
961        assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
962        // L6 cannot go to H7 — stays at H6
963        assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
964    }
965
966    #[test]
967    fn test_fix_cascade_respects_inline_disable_on_subordinate() {
968        // A subordinate heading on a markdownlint-disable-line MD025 line must not
969        // be cascade-fixed: the inline disable explicitly opts that line out.
970        let rule = MD025SingleTitle::default();
971
972        let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
973        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974        let fixed = rule.fix(&ctx).unwrap();
975
976        assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
977        // ## Skip has an inline disable — cascade must not touch it.
978        // Use exact-prefix matching to avoid "## Skip" matching inside "### Skip".
979        let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
980        assert!(
981            skip_line.starts_with("## Skip"),
982            "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
983        );
984        // ## Cascade has no disable — it falls in the section and must cascade
985        assert!(
986            fixed.contains("### Cascade"),
987            "Non-disabled subordinate should cascade to level 3: {fixed}"
988        );
989    }
990
991    #[test]
992    fn test_section_indicator_whole_word_matching() {
993        // Bug: substring matching causes false matches (e.g., "reindex" matches " index")
994        let config = md025_config::MD025Config {
995            allow_document_sections: true,
996            ..Default::default()
997        };
998        let rule = MD025SingleTitle::from_config_struct(config);
999
1000        // These should NOT match section indicators (they contain indicators as substrings)
1001        let false_positive_cases = vec![
1002            "# Main Title\n\n# Understanding Reindex Operations",
1003            "# Main Title\n\n# The Summarization Pipeline",
1004            "# Main Title\n\n# Data Indexing Strategy",
1005            "# Main Title\n\n# Unsupported Browsers",
1006        ];
1007
1008        for case in false_positive_cases {
1009            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1010            let result = rule.check(&ctx).unwrap();
1011            assert_eq!(
1012                result.len(),
1013                1,
1014                "Should flag duplicate H1 (not a section indicator): {case}"
1015            );
1016        }
1017
1018        // These SHOULD still match as legitimate section indicators
1019        let true_positive_cases = vec![
1020            "# Main Title\n\n# Index",
1021            "# Main Title\n\n# Summary",
1022            "# Main Title\n\n# About",
1023            "# Main Title\n\n# References",
1024        ];
1025
1026        for case in true_positive_cases {
1027            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1028            let result = rule.check(&ctx).unwrap();
1029            assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1030        }
1031    }
1032}