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