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