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::regex_cache::{
8    HR_ASTERISK, HR_DASH, HR_SPACED_ASTERISK, HR_SPACED_DASH, HR_SPACED_UNDERSCORE, HR_UNDERSCORE,
9};
10use toml;
11
12mod md025_config;
13use md025_config::MD025Config;
14
15#[derive(Clone, Default)]
16pub struct MD025SingleTitle {
17    config: MD025Config,
18}
19
20impl MD025SingleTitle {
21    pub fn new(level: usize, front_matter_title: &str) -> Self {
22        Self {
23            config: MD025Config {
24                level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
25                front_matter_title: front_matter_title.to_string(),
26                allow_document_sections: true,
27                allow_with_separators: true,
28            },
29        }
30    }
31
32    pub fn strict() -> Self {
33        Self {
34            config: MD025Config {
35                level: HeadingLevel::new(1).unwrap(),
36                front_matter_title: "title".to_string(),
37                allow_document_sections: false,
38                allow_with_separators: false,
39            },
40        }
41    }
42
43    pub fn from_config_struct(config: MD025Config) -> Self {
44        Self { config }
45    }
46
47    /// Check if the document's frontmatter contains a title field matching the configured key
48    fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
49        if self.config.front_matter_title.is_empty() {
50            return false;
51        }
52
53        let content_lines = ctx.raw_lines();
54        if content_lines.first().map(|l| l.trim()) != Some("---") {
55            return false;
56        }
57
58        for (idx, line) in content_lines.iter().enumerate().skip(1) {
59            if line.trim() == "---" {
60                let front_matter_content = content_lines[1..idx].join("\n");
61                return front_matter_content
62                    .lines()
63                    .any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
64            }
65        }
66
67        false
68    }
69
70    /// Check if a heading text suggests it's a legitimate document section
71    fn is_document_section_heading(&self, heading_text: &str) -> bool {
72        if !self.config.allow_document_sections {
73            return false;
74        }
75
76        let lower_text = heading_text.to_lowercase();
77
78        // Common section names that are legitimate as separate H1s
79        let section_indicators = [
80            "appendix",
81            "appendices",
82            "reference",
83            "references",
84            "bibliography",
85            "index",
86            "indices",
87            "glossary",
88            "glossaries",
89            "conclusion",
90            "conclusions",
91            "summary",
92            "executive summary",
93            "acknowledgment",
94            "acknowledgments",
95            "acknowledgement",
96            "acknowledgements",
97            "about",
98            "contact",
99            "license",
100            "legal",
101            "changelog",
102            "change log",
103            "history",
104            "faq",
105            "frequently asked questions",
106            "troubleshooting",
107            "support",
108            "installation",
109            "setup",
110            "getting started",
111            "api reference",
112            "api documentation",
113            "examples",
114            "tutorials",
115            "guides",
116        ];
117
118        // Check if the heading matches these patterns using whole-word matching
119        let words: Vec<&str> = lower_text.split_whitespace().collect();
120        section_indicators.iter().any(|&indicator| {
121            // Multi-word indicators need contiguous word matching
122            let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
123            let starts_with_indicator = if indicator_words.len() == 1 {
124                words.first() == Some(&indicator)
125            } else {
126                words.len() >= indicator_words.len()
127                    && words[..indicator_words.len()] == indicator_words[..]
128            };
129
130            starts_with_indicator ||
131            lower_text.starts_with(&format!("{indicator}:")) ||
132            // Whole-word match anywhere in the heading
133            words.contains(&indicator) ||
134            // Handle multi-word indicators appearing as a contiguous subsequence
135            (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
136            // Handle appendix numbering like "Appendix A", "Appendix 1"
137            (indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
138                let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
139                matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
140            })
141        })
142    }
143
144    /// Check if a line is a horizontal rule
145    fn is_horizontal_rule(line: &str) -> bool {
146        let trimmed = line.trim();
147        HR_DASH.is_match(trimmed)
148            || HR_ASTERISK.is_match(trimmed)
149            || HR_UNDERSCORE.is_match(trimmed)
150            || HR_SPACED_DASH.is_match(trimmed)
151            || HR_SPACED_ASTERISK.is_match(trimmed)
152            || HR_SPACED_UNDERSCORE.is_match(trimmed)
153    }
154
155    /// Build a demoted heading line. Returns the replacement line and whether
156    /// the next line (Setext underline) should be skipped.
157    fn build_demoted_heading(
158        heading: &crate::lint_context::types::HeadingInfo,
159        line_info: &crate::lint_context::types::LineInfo,
160        content: &str,
161        delta: usize,
162    ) -> (String, bool) {
163        let new_level = (heading.level as usize + delta).min(6);
164
165        let style = match heading.style {
166            crate::lint_context::HeadingStyle::ATX => {
167                if heading.has_closing_sequence {
168                    crate::rules::heading_utils::HeadingStyle::AtxClosed
169                } else {
170                    crate::rules::heading_utils::HeadingStyle::Atx
171                }
172            }
173            crate::lint_context::HeadingStyle::Setext1 => {
174                if new_level <= 2 {
175                    if new_level == 1 {
176                        crate::rules::heading_utils::HeadingStyle::Setext1
177                    } else {
178                        crate::rules::heading_utils::HeadingStyle::Setext2
179                    }
180                } else {
181                    crate::rules::heading_utils::HeadingStyle::Atx
182                }
183            }
184            crate::lint_context::HeadingStyle::Setext2 => {
185                if new_level <= 2 {
186                    crate::rules::heading_utils::HeadingStyle::Setext2
187                } else {
188                    crate::rules::heading_utils::HeadingStyle::Atx
189                }
190            }
191        };
192
193        let replacement = if heading.text.is_empty() {
194            match style {
195                crate::rules::heading_utils::HeadingStyle::Atx
196                | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => "#".repeat(new_level),
197                crate::rules::heading_utils::HeadingStyle::AtxClosed
198                | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
199                    format!("{} {}", "#".repeat(new_level), "#".repeat(new_level))
200                }
201                crate::rules::heading_utils::HeadingStyle::Setext1
202                | crate::rules::heading_utils::HeadingStyle::Setext2
203                | crate::rules::heading_utils::HeadingStyle::Consistent => "#".repeat(new_level),
204            }
205        } else {
206            crate::rules::heading_utils::HeadingUtils::convert_heading_style(&heading.raw_text, new_level as u32, style)
207        };
208
209        let line = line_info.content(content);
210        let original_indent = &line[..line_info.indent];
211        let result = format!("{original_indent}{replacement}");
212
213        let should_skip_next = matches!(
214            heading.style,
215            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
216        );
217
218        (result, should_skip_next)
219    }
220
221    /// Check if a line might be a Setext heading underline
222    fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
223        if line_num == 0 || line_num >= ctx.lines.len() {
224            return false;
225        }
226
227        let line = ctx.lines[line_num].content(ctx.content).trim();
228        let prev_line = if line_num > 0 {
229            ctx.lines[line_num - 1].content(ctx.content).trim()
230        } else {
231            ""
232        };
233
234        let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
235        let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
236        let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
237        (is_dash_line || is_equals_line) && prev_line_has_content
238    }
239
240    /// Check if headings are separated by horizontal rules
241    fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
242        if !self.config.allow_with_separators || heading_line == 0 {
243            return false;
244        }
245
246        // Look for horizontal rules in the lines before this heading
247        // Check up to 5 lines before the heading for a horizontal rule
248        let search_start = heading_line.saturating_sub(5);
249
250        for line_num in search_start..heading_line {
251            if line_num >= ctx.lines.len() {
252                continue;
253            }
254
255            let line = &ctx.lines[line_num].content(ctx.content);
256            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
257                // Found a horizontal rule before this heading
258                // Check that there's no other heading between the HR and this heading
259                let has_intermediate_heading =
260                    ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
261
262                if !has_intermediate_heading {
263                    return true;
264                }
265            }
266        }
267
268        false
269    }
270}
271
272impl Rule for MD025SingleTitle {
273    fn name(&self) -> &'static str {
274        "MD025"
275    }
276
277    fn description(&self) -> &'static str {
278        "Multiple top-level headings in the same document"
279    }
280
281    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
282        // Early return for empty content
283        if ctx.lines.is_empty() {
284            return Ok(Vec::new());
285        }
286
287        let mut warnings = Vec::new();
288
289        let found_title_in_front_matter = self.has_front_matter_title(ctx);
290
291        // Find all headings at the target level using cached information
292        let mut target_level_headings = Vec::new();
293        for (line_num, line_info) in ctx.lines.iter().enumerate() {
294            if let Some(heading) = &line_info.heading
295                && heading.level as usize == self.config.level.as_usize()
296                && heading.is_valid
297            // Skip malformed headings like `#NoSpace`
298            {
299                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
300                if line_info.visual_indent >= 4 || line_info.in_code_block {
301                    continue;
302                }
303                target_level_headings.push(line_num);
304            }
305        }
306
307        // Determine which headings to flag as duplicates.
308        // If frontmatter has a title, it counts as the first heading,
309        // so ALL body headings at the target level are duplicates.
310        // Otherwise, skip the first body heading and flag the rest.
311        let headings_to_flag: &[usize] = if found_title_in_front_matter {
312            &target_level_headings
313        } else if target_level_headings.len() > 1 {
314            &target_level_headings[1..]
315        } else {
316            &[]
317        };
318
319        if !headings_to_flag.is_empty() {
320            for &line_num in headings_to_flag {
321                if let Some(heading) = &ctx.lines[line_num].heading {
322                    let heading_text = &heading.text;
323
324                    // Check if this heading should be allowed
325                    let should_allow = self.is_document_section_heading(heading_text)
326                        || self.has_separator_before_heading(ctx, line_num);
327
328                    if should_allow {
329                        continue; // Skip flagging this heading
330                    }
331
332                    // Calculate precise character range for the heading text content
333                    let line_content = &ctx.lines[line_num].content(ctx.content);
334                    let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
335                        pos
336                    } else {
337                        // Fallback: find after hash markers for ATX headings
338                        if line_content.trim_start().starts_with('#') {
339                            let trimmed = line_content.trim_start();
340                            let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
341                            let after_hashes = &trimmed[hash_count..];
342                            let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
343                            (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
344                        } else {
345                            0 // Setext headings start at beginning
346                        }
347                    };
348
349                    let (start_line, start_col, end_line, end_col) = calculate_match_range(
350                        line_num + 1, // Convert to 1-indexed
351                        line_content,
352                        text_start_in_line,
353                        heading_text.len(),
354                    );
355
356                    // For Setext headings, the fix range must cover both
357                    // the text line and the underline line
358                    let is_setext = matches!(
359                        heading.style,
360                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
361                    );
362                    let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
363                        // Cover text line + underline line
364                        let text_range = ctx.line_index.line_content_range(line_num + 1);
365                        let underline_range = ctx.line_index.line_content_range(line_num + 2);
366                        text_range.start..underline_range.end
367                    } else {
368                        ctx.line_index.line_content_range(line_num + 1)
369                    };
370
371                    let replacement = {
372                        let leading_spaces = line_content.len() - line_content.trim_start().len();
373                        let indentation = " ".repeat(leading_spaces);
374                        let raw = &heading.raw_text;
375                        if raw.is_empty() {
376                            format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
377                        } else {
378                            format!(
379                                "{}{} {}",
380                                indentation,
381                                "#".repeat(self.config.level.as_usize() + 1),
382                                raw
383                            )
384                        }
385                    };
386
387                    warnings.push(LintWarning {
388                        rule_name: Some(self.name().to_string()),
389                        message: format!(
390                            "Multiple top-level headings (level {}) in the same document",
391                            self.config.level.as_usize()
392                        ),
393                        line: start_line,
394                        column: start_col,
395                        end_line,
396                        end_column: end_col,
397                        severity: Severity::Error,
398                        fix: Some(Fix {
399                            range: fix_range,
400                            replacement,
401                        }),
402                    });
403                }
404            }
405        }
406
407        Ok(warnings)
408    }
409
410    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
411        let mut fixed_lines = Vec::new();
412        // If frontmatter has a title, treat it as the first heading at the target level,
413        // so all body headings at that level get demoted.
414        let mut found_first = self.has_front_matter_title(ctx);
415        let mut skip_next = false;
416        // Track the demotion delta for cascading child headings.
417        // When a duplicate target-level heading is demoted by +1, all headings
418        // in its section (until the next target-level heading) also shift by +1.
419        let mut current_delta: usize = 0;
420
421        for (line_num, line_info) in ctx.lines.iter().enumerate() {
422            if skip_next {
423                skip_next = false;
424                continue;
425            }
426
427            // Skip lines where this rule is disabled by inline config
428            if ctx.inline_config().is_rule_disabled(self.name(), line_num + 1) {
429                fixed_lines.push(line_info.content(ctx.content).to_string());
430                if let Some(heading) = &line_info.heading {
431                    // A target-level heading in a disabled region is preserved as-is,
432                    // so it resets the cascade delta (it's not demoted)
433                    if heading.level as usize == self.config.level.as_usize() {
434                        current_delta = 0;
435                    }
436                    // For Setext headings, also preserve the underline
437                    if matches!(
438                        heading.style,
439                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
440                    ) && line_num + 1 < ctx.lines.len()
441                    {
442                        fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
443                        skip_next = true;
444                    }
445                }
446                continue;
447            }
448
449            if let Some(heading) = &line_info.heading {
450                if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
451                    if !found_first {
452                        found_first = true;
453                        current_delta = 0;
454                        // Keep the first heading as-is
455                        fixed_lines.push(line_info.content(ctx.content).to_string());
456
457                        // For Setext headings, also add the underline
458                        if matches!(
459                            heading.style,
460                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
461                        ) && line_num + 1 < ctx.lines.len()
462                        {
463                            fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
464                            skip_next = true;
465                        }
466                    } else {
467                        // Check if this heading should be allowed
468                        let should_allow = self.is_document_section_heading(&heading.text)
469                            || self.has_separator_before_heading(ctx, line_num);
470
471                        if should_allow {
472                            current_delta = 0;
473                            // Keep the heading as-is
474                            fixed_lines.push(line_info.content(ctx.content).to_string());
475
476                            // For Setext headings, also add the underline
477                            if matches!(
478                                heading.style,
479                                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
480                            ) && line_num + 1 < ctx.lines.len()
481                            {
482                                fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
483                                skip_next = true;
484                            }
485                        } else {
486                            // Demote this heading and set delta for its children
487                            current_delta = 1;
488                            let (demoted, should_skip) =
489                                Self::build_demoted_heading(heading, line_info, ctx.content, 1);
490                            fixed_lines.push(demoted);
491                            if should_skip && line_num + 1 < ctx.lines.len() {
492                                skip_next = true;
493                            }
494                        }
495                    }
496                } else if current_delta > 0 && !line_info.in_code_block {
497                    // Child heading in a demoted section — cascade the delta
498                    let new_level = heading.level as usize + current_delta;
499                    if new_level > 6 {
500                        // Cannot demote beyond level 6, preserve as-is
501                        fixed_lines.push(line_info.content(ctx.content).to_string());
502                        if matches!(
503                            heading.style,
504                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
505                        ) && line_num + 1 < ctx.lines.len()
506                        {
507                            fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
508                            skip_next = true;
509                        }
510                    } else {
511                        let (demoted, should_skip) =
512                            Self::build_demoted_heading(heading, line_info, ctx.content, current_delta);
513                        fixed_lines.push(demoted);
514                        if should_skip && line_num + 1 < ctx.lines.len() {
515                            skip_next = true;
516                        }
517                    }
518                } else {
519                    // Not in a demoted section or is a target level heading, keep as-is
520                    fixed_lines.push(line_info.content(ctx.content).to_string());
521
522                    // For Setext headings, also add the underline
523                    if matches!(
524                        heading.style,
525                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
526                    ) && line_num + 1 < ctx.lines.len()
527                    {
528                        fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
529                        skip_next = true;
530                    }
531                }
532            } else {
533                // Not a heading line, keep as-is
534                fixed_lines.push(line_info.content(ctx.content).to_string());
535            }
536        }
537
538        let result = fixed_lines.join("\n");
539        if ctx.content.ends_with('\n') {
540            Ok(result + "\n")
541        } else {
542            Ok(result)
543        }
544    }
545
546    /// Get the category of this rule for selective processing
547    fn category(&self) -> RuleCategory {
548        RuleCategory::Heading
549    }
550
551    /// Check if this rule should be skipped for performance
552    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
553        // Skip if content is empty
554        if ctx.content.is_empty() {
555            return true;
556        }
557
558        // Skip if no heading markers at all
559        if !ctx.likely_has_headings() {
560            return true;
561        }
562
563        let has_fm_title = self.has_front_matter_title(ctx);
564
565        // Fast path: count target level headings efficiently
566        let mut target_level_count = 0;
567        for line_info in &ctx.lines {
568            if let Some(heading) = &line_info.heading
569                && heading.level as usize == self.config.level.as_usize()
570            {
571                // Ignore if indented 4+ spaces (indented code block), inside fenced code block, or PyMdown block
572                if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
573                    continue;
574                }
575                target_level_count += 1;
576
577                // If frontmatter has a title, even 1 body heading is a duplicate
578                if has_fm_title {
579                    return false;
580                }
581
582                // Otherwise, we need more than 1 to have duplicates
583                if target_level_count > 1 {
584                    return false;
585                }
586            }
587        }
588
589        // If we have 0 or 1 target level headings (without frontmatter title), skip
590        target_level_count <= 1
591    }
592
593    fn as_any(&self) -> &dyn std::any::Any {
594        self
595    }
596
597    fn default_config_section(&self) -> Option<(String, toml::Value)> {
598        let json_value = serde_json::to_value(&self.config).ok()?;
599        Some((
600            self.name().to_string(),
601            crate::rule_config_serde::json_to_toml_value(&json_value)?,
602        ))
603    }
604
605    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
606    where
607        Self: Sized,
608    {
609        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
610        Box::new(Self::from_config_struct(rule_config))
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn test_with_cached_headings() {
620        let rule = MD025SingleTitle::default();
621
622        // Test with only one level-1 heading
623        let content = "# Title\n\n## Section 1\n\n## Section 2";
624        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626        assert!(result.is_empty());
627
628        // Test with multiple level-1 headings (non-section names) - should flag
629        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
630        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631        let result = rule.check(&ctx).unwrap();
632        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
633        assert_eq!(result[0].line, 5);
634
635        // Test with front matter title and a level-1 heading - should flag the body H1
636        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
637        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638        let result = rule.check(&ctx).unwrap();
639        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
640        assert_eq!(result[0].line, 5);
641    }
642
643    #[test]
644    fn test_allow_document_sections() {
645        // Need to create rule with allow_document_sections = true
646        let config = md025_config::MD025Config {
647            allow_document_sections: true,
648            ..Default::default()
649        };
650        let rule = MD025SingleTitle::from_config_struct(config);
651
652        // Test valid document sections that should NOT be flagged
653        let valid_cases = vec![
654            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
655            "# Introduction\n\nContent here\n\n# References\n\nRef content",
656            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
657            "# Manual\n\nContent\n\n# Index\n\nIndex content",
658            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
659            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
660            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
661        ];
662
663        for case in valid_cases {
664            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
665            let result = rule.check(&ctx).unwrap();
666            assert!(result.is_empty(), "Should not flag document sections in: {case}");
667        }
668
669        // Test invalid cases that should still be flagged
670        let invalid_cases = vec![
671            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
672            "# First\n\nContent\n\n# Second Title\n\nMore content",
673        ];
674
675        for case in invalid_cases {
676            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
677            let result = rule.check(&ctx).unwrap();
678            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
679        }
680    }
681
682    #[test]
683    fn test_strict_mode() {
684        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
685
686        // Even document sections should be flagged in strict mode
687        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
688        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let result = rule.check(&ctx).unwrap();
690        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
691    }
692
693    #[test]
694    fn test_bounds_checking_bug() {
695        // Test case that could trigger bounds error in fix generation
696        // When col + self.config.level.as_usize() exceeds line_content.len()
697        let rule = MD025SingleTitle::default();
698
699        // Create content with very short second heading
700        let content = "# First\n#";
701        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702
703        // This should not panic
704        let result = rule.check(&ctx);
705        assert!(result.is_ok());
706
707        // Test the fix as well
708        let fix_result = rule.fix(&ctx);
709        assert!(fix_result.is_ok());
710    }
711
712    #[test]
713    fn test_bounds_checking_edge_case() {
714        // Test case that specifically targets the bounds checking fix
715        // Create a heading where col + self.config.level.as_usize() would exceed line length
716        let rule = MD025SingleTitle::default();
717
718        // Create content where the second heading is just "#" (length 1)
719        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
720        // This should not exceed bounds for "#" but tests the edge case
721        let content = "# First Title\n#";
722        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723
724        // This should not panic and should handle the edge case gracefully
725        let result = rule.check(&ctx);
726        assert!(result.is_ok());
727
728        if let Ok(warnings) = result
729            && !warnings.is_empty()
730        {
731            // Check that the fix doesn't cause a panic
732            let fix_result = rule.fix(&ctx);
733            assert!(fix_result.is_ok());
734
735            // The fix should produce valid content
736            if let Ok(fixed_content) = fix_result {
737                assert!(!fixed_content.is_empty());
738                // Should convert the second "#" to "##" (or "## " if there's content)
739                assert!(fixed_content.contains("##"));
740            }
741        }
742    }
743
744    #[test]
745    fn test_horizontal_rule_separators() {
746        // Need to create rule with allow_with_separators = true
747        let config = md025_config::MD025Config {
748            allow_with_separators: true,
749            ..Default::default()
750        };
751        let rule = MD025SingleTitle::from_config_struct(config);
752
753        // Test that headings separated by horizontal rules are allowed
754        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.";
755        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756        let result = rule.check(&ctx).unwrap();
757        assert!(
758            result.is_empty(),
759            "Should not flag headings separated by horizontal rules"
760        );
761
762        // Test that headings without separators are still flagged
763        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.";
764        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765        let result = rule.check(&ctx).unwrap();
766        assert_eq!(result.len(), 1, "Should flag the heading without separator");
767        assert_eq!(result[0].line, 11); // Third title on line 11
768
769        // Test with allow_with_separators = false
770        let strict_rule = MD025SingleTitle::strict();
771        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
772        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = strict_rule.check(&ctx).unwrap();
774        assert_eq!(
775            result.len(),
776            1,
777            "Strict mode should flag all multiple H1s regardless of separators"
778        );
779    }
780
781    #[test]
782    fn test_python_comments_in_code_blocks() {
783        let rule = MD025SingleTitle::default();
784
785        // Test that Python comments in code blocks are not treated as headers
786        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.";
787        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788        let result = rule.check(&ctx).unwrap();
789        assert!(
790            result.is_empty(),
791            "Should not flag Python comments in code blocks as headings"
792        );
793
794        // Test the fix method doesn't modify Python comments
795        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
796        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797        let fixed = rule.fix(&ctx).unwrap();
798        assert!(
799            fixed.contains("# Python comment"),
800            "Fix should preserve Python comments in code blocks"
801        );
802        assert!(
803            fixed.contains("## Second Title"),
804            "Fix should demote the actual second heading"
805        );
806    }
807
808    #[test]
809    fn test_fix_preserves_attribute_lists() {
810        let rule = MD025SingleTitle::strict();
811
812        // Duplicate H1 with attribute list - fix should demote to H2 while preserving attrs
813        let content = "# First Title\n\n# Second Title { #custom-id .special }";
814        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815
816        // Should flag the second H1
817        let warnings = rule.check(&ctx).unwrap();
818        assert_eq!(warnings.len(), 1);
819        // Per-warning fix demotes the heading itself (cascade is handled by fix() method)
820        assert!(warnings[0].fix.is_some());
821
822        // Verify fix() preserves attribute list
823        let fixed = rule.fix(&ctx).unwrap();
824        assert!(
825            fixed.contains("## Second Title { #custom-id .special }"),
826            "fix() should demote to H2 while preserving attribute list, got: {fixed}"
827        );
828    }
829
830    #[test]
831    fn test_frontmatter_title_counts_as_h1() {
832        let rule = MD025SingleTitle::default();
833
834        // Frontmatter with title + one body H1 → should warn on the body H1
835        let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
836        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let result = rule.check(&ctx).unwrap();
838        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
839        assert_eq!(result[0].line, 5);
840    }
841
842    #[test]
843    fn test_frontmatter_title_with_multiple_body_h1s() {
844        let config = md025_config::MD025Config {
845            front_matter_title: "title".to_string(),
846            ..Default::default()
847        };
848        let rule = MD025SingleTitle::from_config_struct(config);
849
850        // Frontmatter with title + multiple body H1s → should warn on ALL body H1s
851        let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
852        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853        let result = rule.check(&ctx).unwrap();
854        assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
855        assert_eq!(result[0].line, 5);
856        assert_eq!(result[1].line, 9);
857    }
858
859    #[test]
860    fn test_frontmatter_without_title_no_warning() {
861        let rule = MD025SingleTitle::default();
862
863        // Frontmatter without title key + one body H1 → no warning
864        let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
865        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867        assert!(result.is_empty(), "Should not flag when frontmatter has no title");
868    }
869
870    #[test]
871    fn test_no_frontmatter_single_h1_no_warning() {
872        let rule = MD025SingleTitle::default();
873
874        // No frontmatter + single body H1 → no warning
875        let content = "# Only Heading\n\nSome content.";
876        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877        let result = rule.check(&ctx).unwrap();
878        assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
879    }
880
881    #[test]
882    fn test_frontmatter_custom_title_key() {
883        // Custom front_matter_title key
884        let config = md025_config::MD025Config {
885            front_matter_title: "heading".to_string(),
886            ..Default::default()
887        };
888        let rule = MD025SingleTitle::from_config_struct(config);
889
890        // Frontmatter with "heading:" key → should count as H1
891        let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
892        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893        let result = rule.check(&ctx).unwrap();
894        assert_eq!(
895            result.len(),
896            1,
897            "Should flag body H1 when custom frontmatter key matches"
898        );
899        assert_eq!(result[0].line, 5);
900
901        // Frontmatter with "title:" but configured for "heading:" → should not count
902        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
903        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904        let result = rule.check(&ctx).unwrap();
905        assert!(
906            result.is_empty(),
907            "Should not flag when frontmatter key doesn't match config"
908        );
909    }
910
911    #[test]
912    fn test_frontmatter_title_empty_config_disables() {
913        // Empty front_matter_title disables frontmatter title detection
914        let rule = MD025SingleTitle::new(1, "");
915
916        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
917        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918        let result = rule.check(&ctx).unwrap();
919        assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
920    }
921
922    #[test]
923    fn test_frontmatter_title_with_level_config() {
924        // When level is set to 2, frontmatter title counts as the first heading at that level
925        let config = md025_config::MD025Config {
926            level: HeadingLevel::new(2).unwrap(),
927            front_matter_title: "title".to_string(),
928            ..Default::default()
929        };
930        let rule = MD025SingleTitle::from_config_struct(config);
931
932        // Frontmatter with title + body H2 → should flag body H2
933        let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
934        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935        let result = rule.check(&ctx).unwrap();
936        assert_eq!(
937            result.len(),
938            1,
939            "Should flag body H2 when level=2 and frontmatter has title"
940        );
941        assert_eq!(result[0].line, 7);
942    }
943
944    #[test]
945    fn test_frontmatter_title_fix_demotes_body_heading() {
946        let config = md025_config::MD025Config {
947            front_matter_title: "title".to_string(),
948            ..Default::default()
949        };
950        let rule = MD025SingleTitle::from_config_struct(config);
951
952        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
953        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954        let fixed = rule.fix(&ctx).unwrap();
955        assert!(
956            fixed.contains("## Body Heading"),
957            "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
958        );
959        // Frontmatter should be preserved
960        assert!(fixed.contains("---\ntitle: FM Title\n---"));
961    }
962
963    #[test]
964    fn test_frontmatter_title_should_skip_respects_frontmatter() {
965        let rule = MD025SingleTitle::default();
966
967        // With frontmatter title + 1 body H1, should_skip should return false
968        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
969        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970        assert!(
971            !rule.should_skip(&ctx),
972            "should_skip must return false when frontmatter has title and body has H1"
973        );
974
975        // Without frontmatter title + 1 body H1, should_skip should return true
976        let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
977        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978        assert!(
979            rule.should_skip(&ctx),
980            "should_skip should return true with no frontmatter title and single H1"
981        );
982    }
983
984    #[test]
985    fn test_section_indicator_whole_word_matching() {
986        // Bug: substring matching causes false matches (e.g., "reindex" matches " index")
987        let config = md025_config::MD025Config {
988            allow_document_sections: true,
989            ..Default::default()
990        };
991        let rule = MD025SingleTitle::from_config_struct(config);
992
993        // These should NOT match section indicators (they contain indicators as substrings)
994        let false_positive_cases = vec![
995            "# Main Title\n\n# Understanding Reindex Operations",
996            "# Main Title\n\n# The Summarization Pipeline",
997            "# Main Title\n\n# Data Indexing Strategy",
998            "# Main Title\n\n# Unsupported Browsers",
999        ];
1000
1001        for case in false_positive_cases {
1002            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1003            let result = rule.check(&ctx).unwrap();
1004            assert_eq!(
1005                result.len(),
1006                1,
1007                "Should flag duplicate H1 (not a section indicator): {case}"
1008            );
1009        }
1010
1011        // These SHOULD still match as legitimate section indicators
1012        let true_positive_cases = vec![
1013            "# Main Title\n\n# Index",
1014            "# Main Title\n\n# Summary",
1015            "# Main Title\n\n# About",
1016            "# Main Title\n\n# References",
1017        ];
1018
1019        for case in true_positive_cases {
1020            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1021            let result = rule.check(&ctx).unwrap();
1022            assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1023        }
1024    }
1025}