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    /// The byte range one demoted ATX line replaces, and the indentation that
166    /// line keeps.
167    ///
168    /// A setext heading's text is the whole paragraph its underline ends, so the
169    /// span runs from the first of those lines through the underline and the
170    /// indentation is the first line's.
171    fn demotion_span(
172        ctx: &crate::lint_context::LintContext,
173        line_num: usize,
174        heading: &crate::lint_context::HeadingInfo,
175    ) -> (std::ops::Range<usize>, String) {
176        let first_idx = line_num + 1 - heading.text_lines;
177        let is_setext = matches!(
178            heading.style,
179            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
180        );
181        let range = if is_setext && line_num + 2 <= ctx.lines.len() {
182            ctx.line_content_byte_range(first_idx + 1).start..ctx.line_content_byte_range(line_num + 2).end
183        } else {
184            ctx.line_content_byte_range(first_idx + 1)
185        };
186        let first_content = ctx.lines[first_idx].content(ctx.content);
187        let leading_spaces = first_content.len() - first_content.trim_start().len();
188        (range, " ".repeat(leading_spaces))
189    }
190
191    /// Use the structural marker, rather than inline heading text, to retain
192    /// visible dynamic titles while excluding headings inside literal code.
193    fn heading_is_in_literal_code(
194        ctx: &crate::lint_context::LintContext,
195        heading_idx: usize,
196        heading: &crate::lint_context::HeadingInfo,
197    ) -> bool {
198        let marker_idx = if matches!(
199            heading.style,
200            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
201        ) {
202            heading_idx + 1
203        } else {
204            heading_idx
205        };
206        ctx.lines.get(marker_idx).is_some_and(|line| {
207            let marker_offset = line.byte_offset + line.indent;
208            ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
209        })
210    }
211
212    /// Reuse the heading conversion safety checks for each demotion bundle.
213    fn demotion_is_safe(
214        ctx: &crate::lint_context::LintContext,
215        heading_idx: usize,
216        heading: &crate::lint_context::HeadingInfo,
217    ) -> bool {
218        crate::rules::heading_utils::setext_to_atx_is_safe(ctx, heading_idx, heading)
219    }
220
221    fn section_end(ctx: &crate::lint_context::LintContext, heading_idx: usize, target_level: usize) -> usize {
222        ctx.lines
223            .iter()
224            .enumerate()
225            .skip(heading_idx + 1)
226            .find(|&(idx, line)| {
227                line.heading.as_ref().is_some_and(|heading| {
228                    heading.level as usize <= target_level
229                        && !line.in_code_block
230                        && line.visual_indent < 4
231                        && !Self::heading_is_in_literal_code(ctx, idx, heading)
232                })
233            })
234            .map_or(ctx.lines.len(), |(idx, _)| idx)
235    }
236
237    /// A parent and the subordinate headings its fix demotes form one bundle.
238    /// Keep its warning, but withhold the bundle if any required edit is unsafe.
239    fn section_demotion_is_safe(
240        &self,
241        ctx: &crate::lint_context::LintContext,
242        heading_idx: usize,
243        heading: &crate::lint_context::HeadingInfo,
244    ) -> bool {
245        if !Self::demotion_is_safe(ctx, heading_idx, heading) {
246            return false;
247        }
248        let end = Self::section_end(ctx, heading_idx, self.config.level.as_usize());
249        ((heading_idx + 1)..end).all(|idx| {
250            let line = &ctx.lines[idx];
251            let Some(child) = &line.heading else { return true };
252            line.in_code_block
253                || line.visual_indent >= 4
254                || child.level >= 6
255                || Self::heading_is_in_literal_code(ctx, idx, child)
256                || ctx
257                    .inline_config()
258                    .is_rule_disabled("MD025", idx + 2 - child.text_lines)
259                || Self::demotion_is_safe(ctx, idx, child)
260        })
261    }
262
263    /// Check if headings are separated by horizontal rules
264    fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
265        if !self.config.allow_with_separators || heading_line == 0 {
266            return false;
267        }
268
269        // Look for horizontal rules in the lines before this heading
270        // Check up to 5 lines before the heading for a horizontal rule
271        let search_start = heading_line.saturating_sub(5);
272
273        for line_num in search_start..heading_line {
274            if line_num >= ctx.lines.len() {
275                continue;
276            }
277
278            let info = &ctx.lines[line_num];
279            let marker_offset = info.byte_offset + info.indent;
280            if info.in_code_block
281                || info.in_front_matter
282                || info.in_html_comment
283                || ctx.is_inside_template_code(marker_offset)
284                || ctx.is_inside_mdx_code(marker_offset)
285            {
286                continue;
287            }
288            let line = &info.content(ctx.content);
289            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
290                // Found a horizontal rule before this heading
291                // Check that there's no other heading between the HR and this heading
292                let has_intermediate_heading = ((line_num + 1)..heading_line).any(|idx| {
293                    // Resolve a Setext text line to its cached heading before
294                    // checking ownership of the actual underline. This walk is
295                    // bounded by the same five-line separator lookback.
296                    let mut recorded_idx = idx;
297                    while recorded_idx < heading_line
298                        && ctx.lines[recorded_idx].heading.is_none()
299                        && ctx.lines[recorded_idx].is_setext_heading_text
300                    {
301                        recorded_idx += 1;
302                    }
303                    recorded_idx < heading_line
304                        && ctx.lines[recorded_idx].heading.as_ref().is_some_and(|heading| {
305                            let line = &ctx.lines[recorded_idx];
306                            !line.in_code_block
307                                && line.visual_indent < 4
308                                && !Self::heading_is_in_literal_code(ctx, recorded_idx, heading)
309                        })
310                });
311
312                if !has_intermediate_heading {
313                    return true;
314                }
315            }
316        }
317
318        false
319    }
320}
321
322impl Rule for MD025SingleTitle {
323    fn name(&self) -> &'static str {
324        "MD025"
325    }
326
327    fn description(&self) -> &'static str {
328        "Multiple top-level headings in the same document"
329    }
330
331    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
332        // Early return for empty content
333        if ctx.lines.is_empty() {
334            return Ok(Vec::new());
335        }
336
337        let mut warnings = Vec::new();
338
339        let found_title_in_front_matter = self.has_front_matter_title(ctx);
340
341        // Find all headings at the target level using cached information
342        let mut target_level_headings = Vec::new();
343        for (line_num, line_info) in ctx.lines.iter().enumerate() {
344            if let Some(heading) = &line_info.heading
345                && heading.level as usize == self.config.level.as_usize()
346            {
347                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
348                if line_info.visual_indent >= 4
349                    || line_info.in_code_block
350                    || Self::heading_is_in_literal_code(ctx, line_num, heading)
351                {
352                    continue;
353                }
354                target_level_headings.push(line_num);
355            }
356        }
357
358        // Determine which headings to flag as duplicates.
359        // If frontmatter has a title, it counts as the first heading,
360        // so ALL body headings at the target level are duplicates.
361        // Otherwise, skip the first body heading and flag the rest.
362        let headings_to_flag: &[usize] = if found_title_in_front_matter {
363            &target_level_headings
364        } else if target_level_headings.len() > 1 {
365            &target_level_headings[1..]
366        } else {
367            &[]
368        };
369
370        if !headings_to_flag.is_empty() {
371            for &line_num in headings_to_flag {
372                if let Some(heading) = &ctx.lines[line_num].heading {
373                    let heading_text = &heading.text;
374                    // A setext heading's text is the whole paragraph its underline
375                    // ends, so the heading starts on the first of those lines.
376                    let first_idx = line_num + 1 - heading.text_lines;
377
378                    // Check if this heading should be allowed
379                    let should_allow = self.is_document_section_heading(heading_text)
380                        || self.has_separator_before_heading(ctx, first_idx);
381
382                    if should_allow {
383                        continue; // Skip flagging this heading
384                    }
385
386                    // Calculate precise character range for the heading text content
387                    let line_content = &ctx.lines[line_num].content(ctx.content);
388                    let (start_line, start_col, end_line, end_col) = if heading.text_lines > 1 {
389                        // The warning starts at the text on the first line and
390                        // runs to the end of the text on the last.
391                        let first_content = ctx.lines[first_idx].content(ctx.content);
392                        let indent_chars = first_content.len() - first_content.trim_start().len();
393                        (
394                            first_idx + 1,
395                            first_content[..indent_chars].chars().count() + 1,
396                            line_num + 1,
397                            line_content.trim_end().chars().count() + 1,
398                        )
399                    } else {
400                        let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
401                            pos
402                        } else {
403                            // Fallback: find after hash markers for ATX headings
404                            if line_content.trim_start().starts_with('#') {
405                                let trimmed = line_content.trim_start();
406                                let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
407                                let after_hashes = &trimmed[hash_count..];
408                                let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
409                                (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
410                            } else {
411                                0 // Setext headings start at beginning
412                            }
413                        };
414                        calculate_match_range(
415                            line_num + 1, // Convert to 1-indexed
416                            line_content,
417                            text_start_in_line,
418                            heading_text.len(),
419                        )
420                    };
421
422                    let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
423
424                    // Demote to one level below the configured top-level heading.
425                    // Markdown only supports levels 1-6, so if the configured level
426                    // is already 6, the heading cannot be demoted.
427                    let demoted_level = self.config.level.as_usize() + 1;
428                    let fix = if demoted_level > 6 || !self.section_demotion_is_safe(ctx, line_num, heading) {
429                        None
430                    } else {
431                        let raw = if heading.has_closing_sequence {
432                            std::borrow::Cow::Borrowed(heading.raw_text.as_str())
433                        } else {
434                            crate::rules::heading_utils::escape_atx_closing_hashes(&heading.raw_text)
435                        };
436                        let hashes = "#".repeat(demoted_level);
437                        let closing = if heading.has_closing_sequence {
438                            format!(" {}", "#".repeat(demoted_level))
439                        } else {
440                            String::new()
441                        };
442                        let replacement = if raw.is_empty() {
443                            format!("{indentation}{hashes}{closing}")
444                        } else {
445                            format!("{indentation}{hashes} {raw}{closing}")
446                        };
447                        Some(Fix::new(fix_range, replacement))
448                    };
449
450                    warnings.push(LintWarning {
451                        rule_name: Some(self.name().to_string()),
452                        message: format!(
453                            "Multiple top-level headings (level {}) in the same document",
454                            self.config.level.as_usize()
455                        ),
456                        line: start_line,
457                        column: start_col,
458                        end_line,
459                        end_column: end_col,
460                        severity: Severity::Error,
461                        fix,
462                    });
463                }
464            }
465        }
466
467        Ok(warnings)
468    }
469
470    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
471        let warnings = self.check(ctx)?;
472        if warnings.is_empty() {
473            return Ok(ctx.content.to_string());
474        }
475        let warnings =
476            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
477
478        // Build the full fix set: each flagged heading plus every subordinate heading
479        // in its section, all demoted by the same +1 delta. Wrapping cascade fixes in
480        // synthetic LintWarning objects lets apply_warning_fixes handle range sorting
481        // and deduplication automatically.
482        let mut all_warnings = warnings.clone();
483
484        let target_level = self.config.level.as_usize();
485
486        for warning in &warnings {
487            if warning.fix.is_none() {
488                continue;
489            }
490            // warning.line is 1-indexed and points at the heading's first text
491            // line; the heading itself is recorded on the last one, which is
492            // where the section below it starts.
493            let mut heading_line = warning.line - 1;
494            while heading_line + 1 < ctx.lines.len()
495                && ctx.lines[heading_line].heading.is_none()
496                && ctx.lines[heading_line].is_setext_heading_text
497            {
498                heading_line += 1;
499            }
500
501            // Section boundary: the next heading at or above target_level, or end of doc.
502            let section_end = Self::section_end(ctx, heading_line, target_level);
503
504            // Emit a cascade Fix for each subordinate heading inside [heading_line+1, section_end).
505            for line_num in (heading_line + 1)..section_end {
506                let line_info = &ctx.lines[line_num];
507                let Some(heading) = &line_info.heading else {
508                    continue;
509                };
510                if line_info.in_code_block
511                    || line_info.visual_indent >= 4
512                    || Self::heading_is_in_literal_code(ctx, line_num, heading)
513                {
514                    continue;
515                }
516
517                let new_level = heading.level as usize + 1;
518                if new_level > 6 {
519                    // Heading is already at the maximum depth; no fix possible.
520                    continue;
521                }
522
523                let line_content = line_info.content(ctx.content);
524
525                // For Setext headings the fix range must cover every text line and
526                // the underline so they are replaced atomically with one ATX line.
527                let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
528                let first_line = line_num + 2 - heading.text_lines;
529
530                let hashes = "#".repeat(new_level);
531                let raw = if heading.has_closing_sequence {
532                    std::borrow::Cow::Borrowed(heading.raw_text.as_str())
533                } else {
534                    crate::rules::heading_utils::escape_atx_closing_hashes(&heading.raw_text)
535                };
536                let closing = if heading.has_closing_sequence {
537                    format!(" {}", "#".repeat(new_level))
538                } else {
539                    String::new()
540                };
541                let replacement = if raw.is_empty() {
542                    format!("{indentation}{hashes}{closing}")
543                } else {
544                    format!("{indentation}{hashes} {raw}{closing}")
545                };
546
547                all_warnings.push(crate::rule::LintWarning {
548                    rule_name: Some(self.name().to_string()),
549                    message: String::new(),
550                    line: first_line,
551                    column: 1,
552                    end_line: line_num + 1,
553                    end_column: line_content.chars().count(),
554                    severity: crate::rule::Severity::Error,
555                    fix: Some(Fix::new(fix_range, replacement)),
556                });
557            }
558        }
559
560        // Filter cascade warnings through the same inline-disable logic applied to the
561        // original warnings. This ensures that a subordinate heading on a disabled line
562        // (e.g., `<!-- markdownlint-disable-line MD025 -->`) is not cascade-demoted.
563        let all_warnings =
564            crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
565
566        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
567            .map_err(crate::rule::LintError::InvalidInput)
568    }
569
570    /// Get the category of this rule for selective processing
571    fn category(&self) -> RuleCategory {
572        RuleCategory::Heading
573    }
574
575    /// Check if this rule should be skipped for performance
576    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
577        // Skip if content is empty
578        if ctx.content.is_empty() {
579            return true;
580        }
581
582        // Skip if no heading markers at all
583        if !ctx.likely_has_headings() {
584            return true;
585        }
586
587        let has_fm_title = self.has_front_matter_title(ctx);
588
589        // Fast path: count target level headings efficiently
590        let mut target_level_count = 0;
591        for (line_num, line_info) in ctx.lines.iter().enumerate() {
592            if let Some(heading) = &line_info.heading
593                && heading.level as usize == self.config.level.as_usize()
594            {
595                // Ignore if indented 4+ spaces (indented code block), inside fenced code block, or PyMdown block
596                if line_info.visual_indent >= 4
597                    || line_info.in_code_block
598                    || line_info.in_pymdown_block
599                    || Self::heading_is_in_literal_code(ctx, line_num, heading)
600                {
601                    continue;
602                }
603                target_level_count += 1;
604
605                // If frontmatter has a title, even 1 body heading is a duplicate
606                if has_fm_title {
607                    return false;
608                }
609
610                // Otherwise, we need more than 1 to have duplicates
611                if target_level_count > 1 {
612                    return false;
613                }
614            }
615        }
616
617        // If we have 0 or 1 target level headings (without frontmatter title), skip
618        target_level_count <= 1
619    }
620
621    fn as_any(&self) -> &dyn std::any::Any {
622        self
623    }
624
625    crate::impl_rule_config_methods!(MD025Config);
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::lint_context::LintContext;
632
633    #[test]
634    fn test_with_cached_headings() {
635        let rule = MD025SingleTitle::default();
636
637        // Test with only one level-1 heading
638        let content = "# Title\n\n## Section 1\n\n## Section 2";
639        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640        let result = rule.check(&ctx).unwrap();
641        assert!(result.is_empty());
642
643        // Test with multiple level-1 headings (non-section names) - should flag
644        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
645        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646        let result = rule.check(&ctx).unwrap();
647        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
648        assert_eq!(result[0].line, 5);
649
650        // Test with front matter title and a level-1 heading - should flag the body H1
651        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
652        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653        let result = rule.check(&ctx).unwrap();
654        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
655        assert_eq!(result[0].line, 5);
656    }
657
658    #[test]
659    fn test_allow_document_sections() {
660        // Need to create rule with allow_document_sections = true
661        let config = md025_config::MD025Config {
662            allow_document_sections: true,
663            ..Default::default()
664        };
665        let rule = MD025SingleTitle::from_config_struct(config);
666
667        // Test valid document sections that should NOT be flagged
668        let valid_cases = vec![
669            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
670            "# Introduction\n\nContent here\n\n# References\n\nRef content",
671            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
672            "# Manual\n\nContent\n\n# Index\n\nIndex content",
673            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
674            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
675            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
676        ];
677
678        for case in valid_cases {
679            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
680            let result = rule.check(&ctx).unwrap();
681            assert!(result.is_empty(), "Should not flag document sections in: {case}");
682        }
683
684        // Test invalid cases that should still be flagged
685        let invalid_cases = vec![
686            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
687            "# First\n\nContent\n\n# Second Title\n\nMore content",
688        ];
689
690        for case in invalid_cases {
691            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
692            let result = rule.check(&ctx).unwrap();
693            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
694        }
695    }
696
697    #[test]
698    fn test_strict_mode() {
699        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
700
701        // Even document sections should be flagged in strict mode
702        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
703        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704        let result = rule.check(&ctx).unwrap();
705        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
706    }
707
708    #[test]
709    fn test_bounds_checking_bug() {
710        // Test case that could trigger bounds error in fix generation
711        // When col + self.config.level.as_usize() exceeds line_content.len()
712        let rule = MD025SingleTitle::default();
713
714        // Create content with very short second heading
715        let content = "# First\n#";
716        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717
718        // This should not panic
719        let result = rule.check(&ctx);
720        assert!(result.is_ok());
721
722        // Test the fix as well
723        let fix_result = rule.fix(&ctx);
724        assert!(fix_result.is_ok());
725    }
726
727    #[test]
728    fn test_bounds_checking_edge_case() {
729        // Test case that specifically targets the bounds checking fix
730        // Create a heading where col + self.config.level.as_usize() would exceed line length
731        let rule = MD025SingleTitle::default();
732
733        // Create content where the second heading is just "#" (length 1)
734        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
735        // This should not exceed bounds for "#" but tests the edge case
736        let content = "# First Title\n#";
737        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738
739        // This should not panic and should handle the edge case gracefully
740        let result = rule.check(&ctx);
741        assert!(result.is_ok());
742
743        if let Ok(warnings) = result
744            && !warnings.is_empty()
745        {
746            // Check that the fix doesn't cause a panic
747            let fix_result = rule.fix(&ctx);
748            assert!(fix_result.is_ok());
749
750            // The fix should produce valid content
751            if let Ok(fixed_content) = fix_result {
752                assert!(!fixed_content.is_empty());
753                // Should convert the second "#" to "##" (or "## " if there's content)
754                assert!(fixed_content.contains("##"));
755            }
756        }
757    }
758
759    #[test]
760    fn test_horizontal_rule_separators() {
761        // Need to create rule with allow_with_separators = true
762        let config = md025_config::MD025Config {
763            allow_with_separators: true,
764            ..Default::default()
765        };
766        let rule = MD025SingleTitle::from_config_struct(config);
767
768        // Test that headings separated by horizontal rules are allowed
769        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.";
770        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771        let result = rule.check(&ctx).unwrap();
772        assert!(
773            result.is_empty(),
774            "Should not flag headings separated by horizontal rules"
775        );
776
777        // Test that headings without separators are still flagged
778        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.";
779        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780        let result = rule.check(&ctx).unwrap();
781        assert_eq!(result.len(), 1, "Should flag the heading without separator");
782        assert_eq!(result[0].line, 11); // Third title on line 11
783
784        // Test with allow_with_separators = false
785        let strict_rule = MD025SingleTitle::strict();
786        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
787        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788        let result = strict_rule.check(&ctx).unwrap();
789        assert_eq!(
790            result.len(),
791            1,
792            "Strict mode should flag all multiple H1s regardless of separators"
793        );
794    }
795
796    #[test]
797    fn test_python_comments_in_code_blocks() {
798        let rule = MD025SingleTitle::default();
799
800        // Test that Python comments in code blocks are not treated as headers
801        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.";
802        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803        let result = rule.check(&ctx).unwrap();
804        assert!(
805            result.is_empty(),
806            "Should not flag Python comments in code blocks as headings"
807        );
808
809        // Test the fix method doesn't modify Python comments
810        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
811        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let fixed = rule.fix(&ctx).unwrap();
813        assert!(
814            fixed.contains("# Python comment"),
815            "Fix should preserve Python comments in code blocks"
816        );
817        assert!(
818            fixed.contains("## Second Title"),
819            "Fix should demote the actual second heading"
820        );
821    }
822
823    #[test]
824    fn test_fix_preserves_attribute_lists() {
825        let rule = MD025SingleTitle::strict();
826
827        // Duplicate H1 with attribute list - fix should demote to H2 while preserving attrs
828        let content = "# First Title\n\n# Second Title { #custom-id .special }";
829        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830
831        // Should flag the second H1
832        let warnings = rule.check(&ctx).unwrap();
833        assert_eq!(warnings.len(), 1);
834        // Per-warning fix demotes the heading itself (cascade is handled by fix() method)
835        assert!(warnings[0].fix.is_some());
836
837        // Verify fix() preserves attribute list
838        let fixed = rule.fix(&ctx).unwrap();
839        assert!(
840            fixed.contains("## Second Title { #custom-id .special }"),
841            "fix() should demote to H2 while preserving attribute list, got: {fixed}"
842        );
843    }
844
845    #[test]
846    fn test_frontmatter_title_counts_as_h1() {
847        let rule = MD025SingleTitle::default();
848
849        // Frontmatter with title + one body H1 → should warn on the body H1
850        let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
851        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852        let result = rule.check(&ctx).unwrap();
853        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
854        assert_eq!(result[0].line, 5);
855    }
856
857    #[test]
858    fn test_frontmatter_title_with_multiple_body_h1s() {
859        let config = md025_config::MD025Config {
860            front_matter_title: "title".to_string(),
861            ..Default::default()
862        };
863        let rule = MD025SingleTitle::from_config_struct(config);
864
865        // Frontmatter with title + multiple body H1s → should warn on ALL body H1s
866        let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
867        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868        let result = rule.check(&ctx).unwrap();
869        assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
870        assert_eq!(result[0].line, 5);
871        assert_eq!(result[1].line, 9);
872    }
873
874    #[test]
875    fn test_frontmatter_without_title_no_warning() {
876        let rule = MD025SingleTitle::default();
877
878        // Frontmatter without title key + one body H1 → no warning
879        let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
880        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881        let result = rule.check(&ctx).unwrap();
882        assert!(result.is_empty(), "Should not flag when frontmatter has no title");
883    }
884
885    #[test]
886    fn test_no_frontmatter_single_h1_no_warning() {
887        let rule = MD025SingleTitle::default();
888
889        // No frontmatter + single body H1 → no warning
890        let content = "# Only Heading\n\nSome content.";
891        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892        let result = rule.check(&ctx).unwrap();
893        assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
894    }
895
896    #[test]
897    fn test_frontmatter_custom_title_key() {
898        // Custom front_matter_title key
899        let config = md025_config::MD025Config {
900            front_matter_title: "heading".to_string(),
901            ..Default::default()
902        };
903        let rule = MD025SingleTitle::from_config_struct(config);
904
905        // Frontmatter with "heading:" key → should count as H1
906        let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
907        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908        let result = rule.check(&ctx).unwrap();
909        assert_eq!(
910            result.len(),
911            1,
912            "Should flag body H1 when custom frontmatter key matches"
913        );
914        assert_eq!(result[0].line, 5);
915
916        // Frontmatter with "title:" but configured for "heading:" → should not count
917        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
918        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let result = rule.check(&ctx).unwrap();
920        assert!(
921            result.is_empty(),
922            "Should not flag when frontmatter key doesn't match config"
923        );
924    }
925
926    #[test]
927    fn test_frontmatter_title_empty_config_disables() {
928        // Empty front_matter_title disables frontmatter title detection
929        let rule = MD025SingleTitle::new(1, "");
930
931        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
932        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933        let result = rule.check(&ctx).unwrap();
934        assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
935    }
936
937    #[test]
938    fn test_frontmatter_title_with_level_config() {
939        // When level is set to 2, frontmatter title counts as the first heading at that level
940        let config = md025_config::MD025Config {
941            level: HeadingLevel::new(2).unwrap(),
942            front_matter_title: "title".to_string(),
943            ..Default::default()
944        };
945        let rule = MD025SingleTitle::from_config_struct(config);
946
947        // Frontmatter with title + body H2 → should flag body H2
948        let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
949        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950        let result = rule.check(&ctx).unwrap();
951        assert_eq!(
952            result.len(),
953            1,
954            "Should flag body H2 when level=2 and frontmatter has title"
955        );
956        assert_eq!(result[0].line, 7);
957    }
958
959    #[test]
960    fn test_frontmatter_title_fix_demotes_body_heading() {
961        let config = md025_config::MD025Config {
962            front_matter_title: "title".to_string(),
963            ..Default::default()
964        };
965        let rule = MD025SingleTitle::from_config_struct(config);
966
967        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
968        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969        let fixed = rule.fix(&ctx).unwrap();
970        assert!(
971            fixed.contains("## Body Heading"),
972            "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
973        );
974        // Frontmatter should be preserved
975        assert!(fixed.contains("---\ntitle: FM Title\n---"));
976    }
977
978    #[test]
979    fn test_frontmatter_title_should_skip_respects_frontmatter() {
980        let rule = MD025SingleTitle::default();
981
982        // With frontmatter title + 1 body H1, should_skip should return false
983        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
984        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985        assert!(
986            !rule.should_skip(&ctx),
987            "should_skip must return false when frontmatter has title and body has H1"
988        );
989
990        // Without frontmatter title + 1 body H1, should_skip should return true
991        let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
992        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993        assert!(
994            rule.should_skip(&ctx),
995            "should_skip should return true with no frontmatter title and single H1"
996        );
997    }
998
999    #[test]
1000    fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
1001        let rule = MD025SingleTitle::default();
1002
1003        // Exact reproduction from issue #573
1004        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";
1005        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006        let fixed = rule.fix(&ctx).unwrap();
1007
1008        assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
1009        assert!(
1010            fixed.contains("## 1_2\n"),
1011            "Duplicate H1 must be demoted to H2: {fixed}"
1012        );
1013        assert!(
1014            fixed.contains("### 1_2-2_1"),
1015            "H2 under demoted H1 must cascade to H3: {fixed}"
1016        );
1017        assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
1018        assert!(
1019            fixed.contains("### 1_3-2_1"),
1020            "H2 under third demoted H1 must cascade to H3: {fixed}"
1021        );
1022        assert!(
1023            fixed.contains("#### 1_3-2_1-3_1"),
1024            "H3 under third demoted H1 must cascade to H4: {fixed}"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_fix_cascades_single_section_only() {
1030        let rule = MD025SingleTitle::default();
1031
1032        // Sub-headings of a demoted section must not affect sub-headings of other sections
1033        let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
1034        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035        let fixed = rule.fix(&ctx).unwrap();
1036
1037        assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
1038        assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
1039        assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
1040        assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
1041        assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
1042    }
1043
1044    #[test]
1045    fn test_fix_cascade_stops_at_next_same_level() {
1046        let rule = MD025SingleTitle::default();
1047
1048        // H2 under first demoted section must not bleed into content after the next H1
1049        // (which is itself demoted). The cascade boundary is the next heading at or above
1050        // the original target level.
1051        let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
1052        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053        let fixed = rule.fix(&ctx).unwrap();
1054
1055        assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
1056        assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
1057        assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
1058        assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
1059        assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
1060        // Original first H1 still at level 1
1061        assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
1062    }
1063
1064    #[test]
1065    fn test_fix_cascade_does_not_exceed_level_6() {
1066        // A heading at level 6 under a demoted section cannot go deeper; it stays at 6.
1067        let rule = MD025SingleTitle::default();
1068
1069        // Build a chain: H1, H1, H2, H3, H4, H5, H6 under the second H1
1070        let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
1071        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072        let fixed = rule.fix(&ctx).unwrap();
1073
1074        assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
1075        assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
1076        assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
1077        assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
1078        assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
1079        assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
1080        // L6 cannot go to H7 — stays at H6
1081        assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
1082    }
1083
1084    #[test]
1085    fn test_fix_cascade_respects_inline_disable_on_subordinate() {
1086        // A subordinate heading on a markdownlint-disable-line MD025 line must not
1087        // be cascade-fixed: the inline disable explicitly opts that line out.
1088        let rule = MD025SingleTitle::default();
1089
1090        let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
1091        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092        let fixed = rule.fix(&ctx).unwrap();
1093
1094        assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
1095        // ## Skip has an inline disable — cascade must not touch it.
1096        // Use exact-prefix matching to avoid "## Skip" matching inside "### Skip".
1097        let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
1098        assert!(
1099            skip_line.starts_with("## Skip"),
1100            "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
1101        );
1102        // ## Cascade has no disable — it falls in the section and must cascade
1103        assert!(
1104            fixed.contains("### Cascade"),
1105            "Non-disabled subordinate should cascade to level 3: {fixed}"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_section_indicator_whole_word_matching() {
1111        // Bug: substring matching causes false matches (e.g., "reindex" matches " index")
1112        let config = md025_config::MD025Config {
1113            allow_document_sections: true,
1114            ..Default::default()
1115        };
1116        let rule = MD025SingleTitle::from_config_struct(config);
1117
1118        // These should NOT match section indicators (they contain indicators as substrings)
1119        let false_positive_cases = vec![
1120            "# Main Title\n\n# Understanding Reindex Operations",
1121            "# Main Title\n\n# The Summarization Pipeline",
1122            "# Main Title\n\n# Data Indexing Strategy",
1123            "# Main Title\n\n# Unsupported Browsers",
1124        ];
1125
1126        for case in false_positive_cases {
1127            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1128            let result = rule.check(&ctx).unwrap();
1129            assert_eq!(
1130                result.len(),
1131                1,
1132                "Should flag duplicate H1 (not a section indicator): {case}"
1133            );
1134        }
1135
1136        // These SHOULD still match as legitimate section indicators
1137        let true_positive_cases = vec![
1138            "# Main Title\n\n# Index",
1139            "# Main Title\n\n# Summary",
1140            "# Main Title\n\n# About",
1141            "# Main Title\n\n# References",
1142        ];
1143
1144        for case in true_positive_cases {
1145            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1146            let result = rule.check(&ctx).unwrap();
1147            assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1148        }
1149    }
1150
1151    #[test]
1152    fn test_mdg_enforces_single_title() {
1153        // Heading levels carry no meaning in the Gherkin AST, so demoting an
1154        // extra H1 keeps the document valid and MD025 stays enforced.
1155        let rule = MD025SingleTitle::strict();
1156        let content = "# Feature: Checkout\n\n# Rule: Registered customers\n\n# Scenario: Purchase\n";
1157
1158        let standard_ctx =
1159            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160        let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1161
1162        assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 2);
1163        assert_eq!(
1164            rule.check(&mdg_ctx).unwrap().len(),
1165            rule.check(&standard_ctx).unwrap().len(),
1166            "MDG must not differ from Standard"
1167        );
1168
1169        let fixed = rule.fix(&mdg_ctx).unwrap();
1170        assert_eq!(
1171            fixed, "# Feature: Checkout\n\n## Rule: Registered customers\n\n## Scenario: Purchase\n",
1172            "the Gherkin keywords must survive the demotion"
1173        );
1174
1175        let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1176        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
1177    }
1178    #[test]
1179    fn test_literal_titles_do_not_count_as_visible_titles() {
1180        let rule = MD025SingleTitle::default();
1181        for literal in ["# Hidden", "Hidden\n==="] {
1182            for code in [
1183                format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1184                format!("{{{{ \"first\n{literal}\nlast\" }}}}"),
1185                format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1186            ] {
1187                let flavor = if code.starts_with("{{<") {
1188                    crate::config::MarkdownFlavor::Hugo
1189                } else {
1190                    crate::config::MarkdownFlavor::Standard
1191                };
1192                for source in [format!("# Visible\n\n{code}\n"), format!("{code}\n\n# Visible\n")] {
1193                    for ending in ["\n", "\r\n"] {
1194                        let source = source.replace('\n', ending);
1195                        let ctx = LintContext::new(&source, flavor, None);
1196                        assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
1197                        assert!(rule.should_skip(&ctx), "{source}");
1198                        assert_eq!(rule.fix(&ctx).unwrap(), source, "{source}");
1199                    }
1200                }
1201            }
1202        }
1203    }
1204
1205    #[test]
1206    fn test_literal_headings_do_not_participate_in_demotion_cascades() {
1207        let rule = MD025SingleTitle::default();
1208        for literal in ["# Hidden", "## Hidden", "Hidden\n===", "Hidden\n---"] {
1209            for code in [
1210                format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1211                format!("{{{{ \"first\n{literal}\nlast\" }}}}"),
1212                format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1213            ] {
1214                let flavor = if code.starts_with("{{<") {
1215                    crate::config::MarkdownFlavor::Hugo
1216                } else {
1217                    crate::config::MarkdownFlavor::Standard
1218                };
1219                for ending in ["\n", "\r\n"] {
1220                    let source = format!("# Visible\n\n# Duplicate\n\n{code}\n\n## Child\n").replace('\n', ending);
1221                    let expected = format!("# Visible\n\n## Duplicate\n\n{code}\n\n### Child\n").replace('\n', ending);
1222                    let ctx = LintContext::new(&source, flavor, None);
1223                    assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
1224                    assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1225                    let fixed_ctx = LintContext::new(&expected, flavor, None);
1226                    assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1227                    assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1228                }
1229            }
1230        }
1231    }
1232
1233    #[test]
1234    fn test_dynamic_heading_text_remains_visible_to_title_demotion() {
1235        let rule = MD025SingleTitle::default();
1236        for (title, fixed) in [
1237            ("# {{ title }}", "## {{ title }}"),
1238            ("Title {{ title }}\n===", "## Title {{ title }}"),
1239        ] {
1240            let source = format!("# Visible\n\n{title}\n\n## Child\n");
1241            let expected = format!("# Visible\n\n{fixed}\n\n### Child\n");
1242            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1243            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1244            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1245        }
1246    }
1247    #[test]
1248    fn test_unsafe_setext_primary_withholds_its_entire_demotion_section() {
1249        let rule = MD025SingleTitle::default();
1250        for (flavor, text) in [
1251            (
1252                crate::config::MarkdownFlavor::Standard,
1253                "Title {% set unused=\"first\nmiddle\nlast\" %}{{ unused|length }}",
1254            ),
1255            (
1256                crate::config::MarkdownFlavor::Standard,
1257                "Title {{ \"first\nmiddle\nlast\" }}",
1258            ),
1259            (
1260                crate::config::MarkdownFlavor::Hugo,
1261                "Title {{< note title=`first\nmiddle\nlast` >}}",
1262            ),
1263            (
1264                crate::config::MarkdownFlavor::Standard,
1265                "Title <span title=\"first\nmiddle\nlast\">Text</span>",
1266            ),
1267            (crate::config::MarkdownFlavor::Standard, "Title\nline\\\nnext"),
1268            (crate::config::MarkdownFlavor::Standard, "Title\nline  \nnext"),
1269            (
1270                crate::config::MarkdownFlavor::MDX,
1271                "Title <span title={`first\nmiddle\nlast`}>Text</span>",
1272            ),
1273            (crate::config::MarkdownFlavor::Standard, "Title `first \nmiddle`"),
1274            (crate::config::MarkdownFlavor::Standard, "Title `first  \nmiddle`"),
1275            (crate::config::MarkdownFlavor::Standard, "Title `first\n middle`"),
1276        ] {
1277            for ending in ["\n", "\r\n"] {
1278                let first = format!("# Visible\n\n{text}\n===\n\n## Retained child\n\n");
1279                let source = format!("{first}# Other\n\n## Changed child\n").replace('\n', ending);
1280                let expected = format!("{first}## Other\n\n### Changed child\n").replace('\n', ending);
1281                let ctx = LintContext::new(&source, flavor, None);
1282                let warnings = rule.check(&ctx).unwrap();
1283                assert_eq!(warnings.len(), 2, "{source}: {warnings:?}");
1284                assert!(warnings[0].fix.is_none(), "{source}: {warnings:?}");
1285                assert!(warnings[1].fix.is_some());
1286                assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1287                assert_eq!(rule.fix(&LintContext::new(&expected, flavor, None)).unwrap(), expected);
1288            }
1289        }
1290    }
1291
1292    #[test]
1293    fn test_unsafe_setext_child_withholds_parent_and_siblings_together() {
1294        let rule = MD025SingleTitle::default();
1295        for (flavor, text) in [
1296            (
1297                crate::config::MarkdownFlavor::Standard,
1298                "Title {% set unused=\"first\nmiddle\nlast\" %}{{ unused|length }}",
1299            ),
1300            (
1301                crate::config::MarkdownFlavor::Standard,
1302                "Title {{ \"first\nmiddle\nlast\" }}",
1303            ),
1304            (
1305                crate::config::MarkdownFlavor::Standard,
1306                "Title <span title=\"first\nmiddle\nlast\">Text</span>",
1307            ),
1308            (crate::config::MarkdownFlavor::Standard, "Title\nline\\\nnext"),
1309            (crate::config::MarkdownFlavor::Standard, "Title\nline  \nnext"),
1310            (crate::config::MarkdownFlavor::Standard, "Title `first \nmiddle`"),
1311            (crate::config::MarkdownFlavor::Standard, "Title `first  \nmiddle`"),
1312            (crate::config::MarkdownFlavor::Standard, "Title `first\n middle`"),
1313        ] {
1314            for ending in ["\n", "\r\n"] {
1315                let first = format!("# Visible\n\n# Duplicate\n\n## Retained sibling\n\n{text}\n---\n\n");
1316                let source = format!("{first}# Other\n\n## Changed child\n").replace('\n', ending);
1317                let expected = format!("{first}## Other\n\n### Changed child\n").replace('\n', ending);
1318                let ctx = LintContext::new(&source, flavor, None);
1319                let warnings = rule.check(&ctx).unwrap();
1320                assert_eq!(warnings.len(), 2, "{source}: {warnings:?}");
1321                assert!(warnings[0].fix.is_none(), "{source}: {warnings:?}");
1322                assert!(warnings[1].fix.is_some());
1323                assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1324                assert_eq!(rule.fix(&LintContext::new(&expected, flavor, None)).unwrap(), expected);
1325            }
1326        }
1327    }
1328    #[test]
1329    fn test_setext_safety_preserves_safe_and_disabled_demotion_edits() {
1330        let rule = MD025SingleTitle::default();
1331        for (title, fixed) in [
1332            ("Title\nnext", "Title next"),
1333            ("Title {{ title }}", "Title {{ title }}"),
1334            ("Title `first\nmiddle`", "Title `first middle`"),
1335            ("Title `first\\\nmiddle`", "Title `first\\ middle`"),
1336            ("Title line\\\\\nnext", "Title line\\\\ next"),
1337            (
1338                "Title <span title=\"value\">Text</span>",
1339                "Title <span title=\"value\">Text</span>",
1340            ),
1341        ] {
1342            let source = format!("# Visible\n\n{title}\n===\n");
1343            let expected = format!("# Visible\n\n## {fixed}\n");
1344            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1345            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1346            assert!(rule.check(&ctx).unwrap()[0].fix.is_some(), "{source}");
1347            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1348        }
1349        let source = "# Visible\n\n# Duplicate\n\n<!-- rumdl-disable MD025 -->\nTitle <span title=\"first\nmiddle\nlast\">Text</span>\n---\n<!-- rumdl-enable MD025 -->\n";
1350        let expected = source.replace("# Duplicate\n", "## Duplicate\n");
1351        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1352        assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
1353        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1354    }
1355    #[test]
1356    fn test_literal_separators_do_not_allow_multiple_titles() {
1357        let rule = MD025SingleTitle::from_config_struct(MD025Config {
1358            allow_with_separators: true,
1359            ..Default::default()
1360        });
1361        for (flavor, source) in [
1362            (
1363                crate::config::MarkdownFlavor::Standard,
1364                "# Visible\n\n{% set unused=\"first\n\n---\n\nlast\" %}{{ unused|length }}\n\n# Duplicate\n",
1365            ),
1366            (
1367                crate::config::MarkdownFlavor::Hugo,
1368                "# Visible\n\n{{< note title=`first\n\n---\n\nlast` >}}\n\n# Duplicate\n",
1369            ),
1370            (
1371                crate::config::MarkdownFlavor::Standard,
1372                "# Visible\n\n```text\n\n---\n\n```\n# Duplicate\n",
1373            ),
1374            (
1375                crate::config::MarkdownFlavor::Standard,
1376                "# Visible\n\n<!--\n\n---\n\n-->\n# Duplicate\n",
1377            ),
1378            (
1379                crate::config::MarkdownFlavor::Standard,
1380                "---\ntitle: Visible\n\n---\n# Duplicate\n",
1381            ),
1382        ] {
1383            for ending in ["\n", "\r\n"] {
1384                let source = source.replace('\n', ending);
1385                let expected = source.replace("# Duplicate", "## Duplicate");
1386                let ctx = LintContext::new(&source, flavor, None);
1387                assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
1388                assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1389            }
1390        }
1391    }
1392
1393    #[test]
1394    fn test_literal_intermediate_headings_do_not_block_real_separator_allowance() {
1395        let rule = MD025SingleTitle::from_config_struct(MD025Config {
1396            allow_with_separators: true,
1397            ..Default::default()
1398        });
1399        for literal in ["# Fake", "Fake\n==="] {
1400            for code in [
1401                format!("{{% set unused=\"\n{literal}\n\" %}}{{{{ unused|length }}}}"),
1402                format!("{{{{< note title=`\n{literal}\n` >}}}}"),
1403            ] {
1404                let flavor = if code.starts_with("{{<") {
1405                    crate::config::MarkdownFlavor::Hugo
1406                } else {
1407                    crate::config::MarkdownFlavor::Standard
1408                };
1409                for ending in ["\n", "\r\n"] {
1410                    let source = format!("# Visible\n\n---\n{code}\n# Duplicate\n").replace('\n', ending);
1411                    let ctx = LintContext::new(&source, flavor, None);
1412                    assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
1413                    assert_eq!(rule.fix(&ctx).unwrap(), source);
1414                }
1415            }
1416        }
1417    }
1418
1419    #[test]
1420    fn test_multiline_inline_titles_preserve_whole_demotion_bundles() {
1421        let rule = MD025SingleTitle::default();
1422        for node in ["[Link]", "![Image]"] {
1423            for (open, close) in [('"', '"'), ('\'', '\''), ('(', ')')] {
1424                let title = format!("{node}(https://example.com {open}first\nlast{close})\n===");
1425                for ending in ["\n", "\r\n"] {
1426                    let source =
1427                        format!("# First\n\n{title}\n\n### Child\n\nBody.\n\n# Adjacent\n").replace('\n', ending);
1428                    let expected =
1429                        format!("# First\n\n{title}\n\n### Child\n\nBody.\n\n## Adjacent\n").replace('\n', ending);
1430                    let ctx =
1431                        crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1432                    let warnings = rule.check(&ctx).unwrap();
1433                    assert_eq!(warnings.len(), 2, "{source}");
1434                    assert!(warnings[0].fix.is_none());
1435                    assert!(warnings[1].fix.is_some());
1436                    assert_eq!(
1437                        crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1438                        expected
1439                    );
1440                    assert_eq!(rule.fix(&ctx).unwrap(), expected);
1441                }
1442            }
1443        }
1444    }
1445
1446    #[test]
1447    fn test_entity_newlines_do_not_block_safe_title_demotions() {
1448        let rule = MD025SingleTitle::default();
1449        let source = "# First\n\n[first\nlast](https://example.com \"title&NewLine;line\")\n===\n\n# Adjacent\n";
1450        let expected = "# First\n\n## [first last](https://example.com \"title&NewLine;line\")\n\n## Adjacent\n";
1451        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1452        let warnings = rule.check(&ctx).unwrap();
1453        assert_eq!(warnings.len(), 2);
1454        assert!(warnings.iter().all(|warning| warning.fix.is_some()));
1455        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1456    }
1457    #[test]
1458    fn setext_demotion_preserves_parent_and_child_literal_hashes() {
1459        let rule = MD025SingleTitle::new(1, "title");
1460        for ending in ["\n", "\r\n"] {
1461            let source = "# First\n\nTitle ###\n===\n\nChild ##\n---\n".replace('\n', ending);
1462            let expected = "# First\n\n## Title \\###\n\n### Child \\##\n".replace('\n', ending);
1463            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1464            let warnings = rule.check(&ctx).unwrap();
1465            assert_eq!(warnings.len(), 1);
1466            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1467            // The advertised edit demotes the parent; fix() also demotes its children.
1468            let parent_only = "# First\n\n## Title \\###\n\nChild ##\n---\n".replace('\n', ending);
1469            assert_eq!(
1470                crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1471                parent_only
1472            );
1473            let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1474            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1475        }
1476    }
1477}