Skip to main content

rumdl_lib/rules/
md001_heading_increment.rs

1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::RuleConfig;
4use crate::rules::front_matter_utils::FrontMatterUtils;
5use crate::rules::heading_utils::HeadingUtils;
6use crate::utils::range_utils::calculate_heading_range;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10/// Configuration for MD001 (Heading increment)
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(rename_all = "kebab-case")]
13pub(super) struct MD001Config {
14    /// Whether a title field in front matter counts as an implicit level-1 heading
15    #[serde(default = "default_front_matter_title", alias = "front_matter_title")]
16    pub front_matter_title: bool,
17
18    /// Regex matching the front matter line that holds the title. When set, it replaces
19    /// the default `title:` lookup, so a document keying its title differently still
20    /// gets the implicit level-1 heading.
21    #[serde(default, alias = "front_matter_title_pattern")]
22    pub front_matter_title_pattern: Option<String>,
23}
24
25fn default_front_matter_title() -> bool {
26    true
27}
28
29impl Default for MD001Config {
30    fn default() -> Self {
31        Self {
32            front_matter_title: default_front_matter_title(),
33            front_matter_title_pattern: None,
34        }
35    }
36}
37
38impl RuleConfig for MD001Config {
39    const RULE_NAME: &'static str = "MD001";
40}
41
42/// Rule MD001: Heading levels should only increment by one level at a time
43///
44/// See [docs/md001.md](../../docs/md001.md) for full documentation, configuration, and examples.
45///
46/// This rule enforces a fundamental principle of document structure: heading levels
47/// should increase by exactly one level at a time to maintain a proper document hierarchy.
48///
49/// ## Purpose
50///
51/// Proper heading structure creates a logical document outline and improves:
52/// - Readability for humans
53/// - Accessibility for screen readers
54/// - Navigation in rendered documents
55/// - Automatic generation of tables of contents
56///
57/// ## Examples
58///
59/// ### Correct Heading Structure
60/// ```markdown
61/// # Heading 1
62/// ## Heading 2
63/// ### Heading 3
64/// ## Another Heading 2
65/// ```
66///
67/// ### Incorrect Heading Structure
68/// ```markdown
69/// # Heading 1
70/// ### Heading 3 (skips level 2)
71/// #### Heading 4
72/// ```
73///
74/// ## Behavior
75///
76/// This rule:
77/// - Tracks the heading level throughout the document
78/// - Validates that each new heading is at most one level deeper than the previous heading
79/// - Allows heading levels to decrease by any amount (e.g., going from ### to #)
80/// - Works with both ATX (`#`) and Setext (underlined) heading styles
81///
82/// ## Fix Behavior
83///
84/// When applying automatic fixes, this rule:
85/// - Changes the level of non-compliant headings to be one level deeper than the previous heading
86/// - Preserves the original heading style (ATX or Setext)
87/// - Maintains indentation and other formatting
88///
89/// ## Rationale
90///
91/// Skipping heading levels (e.g., from `h1` to `h3`) can confuse readers and screen readers
92/// by creating gaps in the document structure. Consistent heading increments create a proper
93/// hierarchical outline essential for well-structured documents.
94///
95/// ## Front Matter Title Support
96///
97/// When `front_matter_title` is enabled (default: true), this rule recognizes a `title:` field
98/// in YAML/TOML frontmatter as an implicit level-1 heading. This allows documents like:
99///
100/// ```markdown
101/// ---
102/// title: My Document
103/// ---
104///
105/// ## First Section
106/// ```
107///
108/// Without triggering a warning about skipping from H1 to H2, since the frontmatter title
109/// counts as the H1.
110///
111#[derive(Debug, Clone)]
112pub struct MD001HeadingIncrement {
113    /// Whether to treat frontmatter title field as an implicit H1
114    pub front_matter_title: bool,
115    /// Optional regex pattern to match custom title fields in frontmatter
116    pub front_matter_title_pattern: Option<Regex>,
117}
118
119impl Default for MD001HeadingIncrement {
120    fn default() -> Self {
121        Self {
122            front_matter_title: true,
123            front_matter_title_pattern: None,
124        }
125    }
126}
127
128/// Result of computing the fix for a single heading
129struct HeadingFixInfo {
130    /// The level after fixing (may equal original if no fix needed)
131    fixed_level: usize,
132    /// The heading style to use for the replacement
133    style: HeadingStyle,
134    /// Whether this heading needs a fix
135    needs_fix: bool,
136}
137
138impl MD001HeadingIncrement {
139    /// Create a new instance with specified settings
140    pub fn new(front_matter_title: bool) -> Self {
141        Self {
142            front_matter_title,
143            front_matter_title_pattern: None,
144        }
145    }
146
147    /// Create a new instance with a custom pattern for matching title fields
148    pub fn with_pattern(front_matter_title: bool, pattern: Option<String>) -> Self {
149        Self::with_pattern_from(front_matter_title, pattern, false)
150    }
151
152    /// [`Self::with_pattern`], told whether a message about the pattern may quote it.
153    /// See [`crate::rule_config_serde::compile_config_regex`].
154    fn with_pattern_from(front_matter_title: bool, pattern: Option<String>, values_withheld: bool) -> Self {
155        let front_matter_title_pattern = pattern.and_then(|p| {
156            crate::rule_config_serde::compile_config_regex(&p, "MD001", "front-matter-title-pattern", values_withheld)
157        });
158
159        Self {
160            front_matter_title,
161            front_matter_title_pattern,
162        }
163    }
164
165    /// An empty pattern is read as "no pattern": compiled, it would match every line
166    /// and turn any front matter at all into an implicit level-1 heading.
167    fn from_config_struct(config: MD001Config, values_withheld: bool) -> Self {
168        Self::with_pattern_from(
169            config.front_matter_title,
170            config.front_matter_title_pattern.filter(|p| !p.is_empty()),
171            values_withheld,
172        )
173    }
174
175    /// Check if the document has a front matter title field
176    pub(crate) fn has_front_matter_title(&self, content: &str) -> bool {
177        if !self.front_matter_title {
178            return false;
179        }
180
181        // If we have a custom pattern, use it to search front matter content
182        if let Some(ref pattern) = self.front_matter_title_pattern {
183            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
184            for line in front_matter_lines {
185                if pattern.is_match(line) {
186                    return true;
187                }
188            }
189            return false;
190        }
191
192        // Default behavior: check for "title:" field
193        FrontMatterUtils::has_front_matter_field(content, "title:")
194    }
195
196    /// Single source of truth for heading level computation and style mapping.
197    ///
198    /// Returns `(HeadingFixInfo, new_prev_level)`. Both `check()` and `fix()` call
199    /// this, making it structurally impossible for them to diverge.
200    fn compute_heading_fix(
201        prev_level: Option<usize>,
202        heading: &crate::lint_context::HeadingInfo,
203    ) -> (HeadingFixInfo, Option<usize>) {
204        let level = heading.level as usize;
205
206        let (fixed_level, needs_fix) = if let Some(prev) = prev_level
207            && level > prev + 1
208        {
209            (prev + 1, true)
210        } else {
211            (level, false)
212        };
213
214        // Map heading style, adjusting Setext variant based on the fixed level
215        let style = match heading.style {
216            crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
217            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
218                if fixed_level == 1 {
219                    HeadingStyle::Setext1
220                } else {
221                    HeadingStyle::Setext2
222                }
223            }
224        };
225
226        let info = HeadingFixInfo {
227            fixed_level,
228            style,
229            needs_fix,
230        };
231        (info, Some(fixed_level))
232    }
233}
234
235impl Rule for MD001HeadingIncrement {
236    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
237        (self.front_matter_title && self.front_matter_title_pattern.is_none())
238            .then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(1))
239    }
240
241    fn name(&self) -> &'static str {
242        "MD001"
243    }
244
245    fn description(&self) -> &'static str {
246        "Heading levels should only increment by one level at a time"
247    }
248
249    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
250        let mut warnings = Vec::new();
251
252        let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
253            Some(1)
254        } else {
255            None
256        };
257
258        for valid_heading in ctx.headings() {
259            let heading = valid_heading.heading;
260            let line_info = valid_heading.line_info;
261            let marker_idx = valid_heading.line_num - 1
262                + usize::from(matches!(
263                    heading.style,
264                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
265                ));
266            if let Some(marker_line) = ctx.lines.get(marker_idx) {
267                let marker_offset = marker_line.byte_offset + marker_line.indent;
268                if ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset) {
269                    continue;
270                }
271            }
272
273            let level = heading.level as usize;
274
275            // Headings disabled via inline config keep their original level for
276            // successor tracking (the user explicitly opted out of fixing them),
277            // and no warning is emitted.
278            if ctx
279                .inline_config()
280                .is_rule_disabled(self.name(), valid_heading.line_num)
281            {
282                prev_level = Some(level);
283                continue;
284            }
285
286            let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
287            prev_level = new_prev;
288
289            if fix_info.needs_fix {
290                let line_content = line_info.content(ctx.content);
291                let fix = if heading.style == crate::lint_context::HeadingStyle::ATX {
292                    // Rewriting only the `#` run leaves every container prefix untouched.
293                    let marker_start = line_info.byte_offset + heading.marker_column;
294                    Some(Fix::new(
295                        marker_start..marker_start + heading.marker.len(),
296                        "#".repeat(fix_info.fixed_level),
297                    ))
298                } else {
299                    let original_indent = &line_content[..line_info.indent];
300                    let replacement = HeadingUtils::convert_heading_style(
301                        &heading.raw_text,
302                        fix_info.fixed_level as u32,
303                        fix_info.style,
304                    );
305                    Some(Fix::new(
306                        ctx.line_content_byte_range(valid_heading.line_num),
307                        format!("{original_indent}{replacement}"),
308                    ))
309                };
310
311                let (start_line, start_col, end_line, end_col) =
312                    calculate_heading_range(valid_heading.first_line_num(), valid_heading.line_num, line_content);
313
314                warnings.push(LintWarning {
315                    rule_name: Some(self.name().to_string()),
316                    line: start_line,
317                    column: start_col,
318                    end_line,
319                    end_column: end_col,
320                    message: format!(
321                        "Expected heading level {}, but found heading level {}",
322                        fix_info.fixed_level, level
323                    ),
324                    severity: Severity::Error,
325                    fix,
326                });
327            }
328        }
329
330        Ok(warnings)
331    }
332
333    fn fixes_from_diagnostics(&self) -> bool {
334        true
335    }
336
337    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
338        if self.should_skip(ctx) {
339            return Ok(ctx.content.to_string());
340        }
341        let warnings = self.check(ctx)?;
342        if warnings.is_empty() {
343            return Ok(ctx.content.to_string());
344        }
345        let warnings =
346            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
347        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
348    }
349
350    fn category(&self) -> RuleCategory {
351        RuleCategory::Heading
352    }
353
354    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
355        // Fast path: check if document likely has headings
356        if ctx.content.is_empty() || !ctx.likely_has_headings() {
357            return true;
358        }
359        // Verify headings actually exist
360        ctx.headings().next().is_none()
361    }
362
363    fn as_any(&self) -> &dyn std::any::Any {
364        self
365    }
366
367    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
368    where
369        Self: Sized,
370    {
371        let rule_config = crate::rule_config_serde::load_rule_config::<MD001Config>(config);
372        Box::new(Self::from_config_struct(
373            rule_config,
374            config.withheld_rule_values.contains("MD001"),
375        ))
376    }
377
378    crate::impl_rule_config_sections!(MD001Config);
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::lint_context::LintContext;
385
386    #[test]
387    fn test_basic_functionality() {
388        let rule = MD001HeadingIncrement::default();
389
390        // Test with valid headings
391        let content = "# Heading 1\n## Heading 2\n### Heading 3";
392        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
393        let result = rule.check(&ctx).unwrap();
394        assert!(result.is_empty());
395
396        // Test with invalid headings: H1 → H3 → H4
397        // H3 skips level 2, and H4 is > fixed(H3=H2) + 1, so both are flagged
398        let content = "# Heading 1\n### Heading 3\n#### Heading 4";
399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400        let result = rule.check(&ctx).unwrap();
401        assert_eq!(result.len(), 2);
402        assert_eq!(result[0].line, 2);
403        assert_eq!(result[1].line, 3);
404    }
405
406    #[test]
407    fn test_frontmatter_title_counts_as_h1() {
408        let rule = MD001HeadingIncrement::default();
409
410        // Frontmatter with title, followed by H2 - should pass
411        let content = "---\ntitle: My Document\n---\n\n## First Section";
412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413        let result = rule.check(&ctx).unwrap();
414        assert!(
415            result.is_empty(),
416            "H2 after frontmatter title should not trigger warning"
417        );
418
419        // Frontmatter with title, followed by H3 - should warn (skips H2)
420        let content = "---\ntitle: My Document\n---\n\n### Third Level";
421        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422        let result = rule.check(&ctx).unwrap();
423        assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
424        assert!(result[0].message.contains("Expected heading level 2"));
425    }
426
427    #[test]
428    fn test_frontmatter_without_title() {
429        let rule = MD001HeadingIncrement::default();
430
431        // Frontmatter without title, followed by H2 - first heading has no predecessor
432        // so it should pass (no increment check for the first heading)
433        let content = "---\nauthor: John\n---\n\n## First Section";
434        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435        let result = rule.check(&ctx).unwrap();
436        assert!(
437            result.is_empty(),
438            "First heading after frontmatter without title has no predecessor"
439        );
440    }
441
442    #[test]
443    fn test_frontmatter_title_disabled() {
444        let rule = MD001HeadingIncrement::new(false);
445
446        // Frontmatter with title, but feature disabled - H2 has no predecessor
447        let content = "---\ntitle: My Document\n---\n\n## First Section";
448        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449        let result = rule.check(&ctx).unwrap();
450        assert!(
451            result.is_empty(),
452            "With front_matter_title disabled, first heading has no predecessor"
453        );
454    }
455
456    #[test]
457    fn test_frontmatter_title_with_subsequent_headings() {
458        let rule = MD001HeadingIncrement::default();
459
460        // Complete document with frontmatter title
461        let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463        let result = rule.check(&ctx).unwrap();
464        assert!(result.is_empty(), "Valid heading progression after frontmatter title");
465    }
466
467    #[test]
468    fn test_frontmatter_title_fix() {
469        let rule = MD001HeadingIncrement::default();
470
471        // Frontmatter with title, H3 should be fixed to H2
472        let content = "---\ntitle: My Document\n---\n\n### Third Level";
473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474        let fixed = rule.fix(&ctx).unwrap();
475        assert!(
476            fixed.contains("## Third Level"),
477            "H3 should be fixed to H2 when frontmatter has title"
478        );
479    }
480
481    #[test]
482    fn test_toml_frontmatter_title() {
483        let rule = MD001HeadingIncrement::default();
484
485        // TOML frontmatter with title
486        let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488        let result = rule.check(&ctx).unwrap();
489        assert!(result.is_empty(), "TOML frontmatter title should count as H1");
490    }
491
492    #[test]
493    fn test_no_frontmatter_no_h1() {
494        let rule = MD001HeadingIncrement::default();
495
496        // No frontmatter, starts with H2 - first heading has no predecessor, so no warning
497        let content = "## First Section\n\n### Subsection";
498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499        let result = rule.check(&ctx).unwrap();
500        assert!(
501            result.is_empty(),
502            "First heading (even if H2) has no predecessor to compare against"
503        );
504    }
505
506    #[test]
507    fn test_fix_preserves_attribute_lists() {
508        let rule = MD001HeadingIncrement::default();
509
510        // H1 followed by H3 with attribute list - fix should preserve { #custom-id }
511        let content = "# Heading 1\n\n### Heading 3 { #custom-id .special }";
512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513
514        // Verify fix() preserves attribute list
515        let fixed = rule.fix(&ctx).unwrap();
516        assert!(
517            fixed.contains("## Heading 3 { #custom-id .special }"),
518            "fix() should preserve attribute list, got: {fixed}"
519        );
520
521        // Verify check() fix output also preserves attribute list
522        let warnings = rule.check(&ctx).unwrap();
523        assert_eq!(warnings.len(), 1);
524        assert!(warnings[0].fix.is_some(), "Should have a fix");
525        let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
526        assert_eq!(applied, fixed);
527        assert!(applied.contains("{ #custom-id .special }"));
528    }
529
530    #[test]
531    fn test_check_single_skip_with_repeated_level() {
532        let rule = MD001HeadingIncrement::default();
533
534        // H1 followed by two H3s: only the first H3 is flagged.
535        // After fixing H3a to H2 (prev+1), H3b at level 3 = 2+1 is valid.
536        let content = "# H1\n### H3a\n### H3b";
537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538
539        let warnings = rule.check(&ctx).unwrap();
540        assert_eq!(warnings.len(), 1, "Only first H3 should be flagged: got {warnings:?}");
541        assert!(warnings[0].message.contains("Expected heading level 2"));
542
543        // Verify check()+apply_all_fixes produces idempotent output
544        let fixed = rule.fix(&ctx).unwrap();
545        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
546        let warnings_after = rule.check(&ctx_fixed).unwrap();
547        assert!(
548            warnings_after.is_empty(),
549            "After fix, no warnings should remain: {fixed:?}, warnings: {warnings_after:?}"
550        );
551    }
552
553    #[test]
554    fn test_check_cascading_skip_produces_idempotent_fix() {
555        let rule = MD001HeadingIncrement::default();
556
557        // H1 → H4 → H5: both are flagged.
558        // H4: prev=1, expected=2. Fixed level tracked as 2.
559        // H5: prev=2, expected=3.
560        // Both fixes applied in one pass produce clean output.
561        let content = "# Title\n#### Deep\n##### Deeper";
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563
564        let warnings = rule.check(&ctx).unwrap();
565        assert_eq!(
566            warnings.len(),
567            2,
568            "Both deep headings should be flagged for idempotent fix"
569        );
570        assert!(warnings[0].message.contains("Expected heading level 2"));
571        assert!(warnings[1].message.contains("Expected heading level 3"));
572
573        // Verify single-pass idempotent fix
574        let fixed = rule.fix(&ctx).unwrap();
575        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
576        let warnings_after = rule.check(&ctx_fixed).unwrap();
577        assert!(
578            warnings_after.is_empty(),
579            "Fixed content should have no warnings: {fixed:?}"
580        );
581    }
582
583    #[test]
584    fn test_check_level_decrease_resets_tracking() {
585        let rule = MD001HeadingIncrement::default();
586
587        // H1 → H3 (flagged) → H1 (decrease, always allowed) → H3 (flagged again)
588        let content = "# Title\n### Sub\n# Another\n### Sub2";
589        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590
591        let warnings = rule.check(&ctx).unwrap();
592        assert_eq!(
593            warnings.len(),
594            2,
595            "Both H3 headings should be flagged (each follows an H1)"
596        );
597
598        // Verify single-pass idempotent fix
599        let fixed = rule.fix(&ctx).unwrap();
600        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
601        assert!(
602            rule.check(&ctx_fixed).unwrap().is_empty(),
603            "Fixed content should pass: {fixed:?}"
604        );
605    }
606
607    /// Applying advertised fix ranges must produce the same document as fix().
608    #[test]
609    fn test_check_and_fix_produce_identical_replacements() {
610        let rule = MD001HeadingIncrement::default();
611
612        let inputs = [
613            "# H1\n### H3\n",
614            "# H1\n#### H4\n##### H5\n",
615            "# H1\n### H3\n# H1b\n### H3b\n",
616            "# H1\n\n### H3 { #custom-id }\n",
617            "---\ntitle: Doc\n---\n\n### Deep\n",
618        ];
619
620        for input in &inputs {
621            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
622            let warnings = rule.check(&ctx).unwrap();
623            let fixed = rule.fix(&ctx).unwrap();
624            let applied = crate::utils::fix_utils::apply_warning_fixes(input, &warnings).unwrap();
625            assert_eq!(
626                applied, fixed,
627                "check() fixes and fix() output diverge (input: {input:?})"
628            );
629        }
630    }
631
632    /// Setext H1 followed by deep ATX heading: Setext heading is untouched,
633    /// ATX heading is fixed to H2.
634    #[test]
635    fn test_setext_headings_mixed_with_atx_cascading() {
636        let rule = MD001HeadingIncrement::default();
637
638        let content = "Setext Title\n============\n\n#### Deep ATX\n";
639        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640
641        let warnings = rule.check(&ctx).unwrap();
642        assert_eq!(warnings.len(), 1);
643        assert!(warnings[0].message.contains("Expected heading level 2"));
644
645        let fixed = rule.fix(&ctx).unwrap();
646        assert!(
647            fixed.contains("## Deep ATX"),
648            "H4 after Setext H1 should be fixed to ATX H2, got: {fixed}"
649        );
650
651        // Verify idempotency
652        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
653        assert!(
654            rule.check(&ctx_fixed).unwrap().is_empty(),
655            "Fixed content should produce no warnings"
656        );
657    }
658
659    /// fix(fix(x)) == fix(x) for various inputs
660    #[test]
661    fn test_fix_idempotent_applied_twice() {
662        let rule = MD001HeadingIncrement::default();
663
664        let inputs = [
665            "# H1\n### H3\n#### H4\n",
666            "## H2\n##### H5\n###### H6\n",
667            "# A\n### B\n# C\n### D\n##### E\n",
668            "# H1\nH2\n--\n#### H4\n",
669            // Setext edge cases
670            "Title\n=====\n",
671            "Title\n=====\n\n#### Deep\n",
672            "Sub\n---\n\n#### Deep\n",
673            "T1\n==\nT2\n--\n#### Deep\n",
674        ];
675
676        for input in &inputs {
677            let ctx1 = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
678            let fixed_once = rule.fix(&ctx1).unwrap();
679
680            let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
681            let fixed_twice = rule.fix(&ctx2).unwrap();
682
683            assert_eq!(
684                fixed_once, fixed_twice,
685                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed_once:?}\nsecond: {fixed_twice:?}"
686            );
687        }
688    }
689
690    /// Setext underline must not be duplicated: fix() should produce the same
691    /// number of lines as the input for valid documents.
692    #[test]
693    fn test_setext_fix_no_underline_duplication() {
694        let rule = MD001HeadingIncrement::default();
695
696        // Setext H1 only — no fix needed, output must be identical
697        let content = "Title\n=====\n";
698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699        let fixed = rule.fix(&ctx).unwrap();
700        assert_eq!(fixed, content, "Valid Setext H1 should be unchanged");
701
702        // Setext H2 only — no fix needed
703        let content = "Sub\n---\n";
704        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705        let fixed = rule.fix(&ctx).unwrap();
706        assert_eq!(fixed, content, "Valid Setext H2 should be unchanged");
707
708        // Two consecutive Setext headings — valid H1 then H2
709        let content = "Title\n=====\nSub\n---\n";
710        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711        let fixed = rule.fix(&ctx).unwrap();
712        assert_eq!(fixed, content, "Valid consecutive Setext headings should be unchanged");
713
714        // Setext H1 at end of file without trailing newline
715        let content = "Title\n=====";
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let fixed = rule.fix(&ctx).unwrap();
718        assert_eq!(fixed, content, "Setext H1 at EOF without newline should be unchanged");
719
720        // Setext H2 followed by deep ATX heading
721        let content = "Sub\n---\n\n#### Deep\n";
722        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723        let fixed = rule.fix(&ctx).unwrap();
724        assert!(
725            fixed.contains("### Deep"),
726            "H4 after Setext H2 should become H3, got: {fixed}"
727        );
728        assert_eq!(
729            fixed.matches("---").count(),
730            1,
731            "Underline should not be duplicated, got: {fixed}"
732        );
733
734        // Underline longer than text must not be normalized for valid headings
735        let content = "Hi\n==========\n";
736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737        let fixed = rule.fix(&ctx).unwrap();
738        assert_eq!(
739            fixed, content,
740            "Valid Setext with long underline must be preserved exactly, got: {fixed}"
741        );
742
743        // Underline shorter than text must not be normalized
744        let content = "Long Title Here\n===\n";
745        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746        let fixed = rule.fix(&ctx).unwrap();
747        assert_eq!(
748            fixed, content,
749            "Valid Setext with short underline must be preserved exactly, got: {fixed}"
750        );
751    }
752
753    /// Roundtrip safety: after fix(), check() must produce no warnings
754    /// across a variety of inputs covering frontmatter, setext, attribute
755    /// lists, cascading skips, and level decreases.
756    #[test]
757    fn test_roundtrip_fix_produces_no_warnings() {
758        let rule = MD001HeadingIncrement::default();
759
760        let inputs = [
761            "# H1\n### H3\n",
762            "# H1\n#### H4\n##### H5\n",
763            "# H1\n### H3\n# H1b\n### H3b\n",
764            "# H1\n\n### H3 { #custom-id }\n",
765            "---\ntitle: Doc\n---\n\n### Deep\n",
766            "Title\n=====\n\n#### Deep\n",
767            "Sub\n---\n\n#### Deep\n",
768            "# A\n### B\n# C\n### D\n##### E\n",
769            "# Title\n#### Deep\n##### Deeper\n###### Deepest\n",
770        ];
771
772        for input in &inputs {
773            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
774            let fixed = rule.fix(&ctx).unwrap();
775
776            let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
777            let warnings_after = rule.check(&ctx_fixed).unwrap();
778            assert!(
779                warnings_after.is_empty(),
780                "Fix should produce clean output for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
781            );
782
783            // Idempotency: fix(fix(x)) == fix(x)
784            let fixed_twice = rule.fix(&ctx_fixed).unwrap();
785            assert_eq!(
786                fixed, fixed_twice,
787                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
788            );
789        }
790    }
791
792    /// Disable-via-inline-config must still update prev_level tracking so that
793    /// subsequent headings are computed relative to the (unfixed) disabled heading.
794    #[test]
795    fn test_inline_disable_preserves_content() {
796        let rule = MD001HeadingIncrement::default();
797
798        // H1, then disabled H4 (kept as-is), then H5 (4+1=5 is valid after disabled H4)
799        let content = "# H1\n\n<!-- rumdl-disable-next-line MD001 -->\n#### H4\n\n##### H5\n";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801
802        let fixed = rule.fix(&ctx).unwrap();
803        // The disabled H4 must remain, and H5 must also remain (valid after prev=4)
804        assert!(fixed.contains("#### H4"), "Disabled heading should be preserved");
805        assert!(fixed.contains("##### H5"), "Heading after disabled should be preserved");
806    }
807    #[test]
808    fn test_literal_headings_do_not_hide_visible_level_jumps() {
809        use crate::config::MarkdownFlavor;
810        let rule = MD001HeadingIncrement::default();
811        for literal in ["### Fake", "## Fake", "\nFake\n---\n"] {
812            for (flavor, code) in [
813                (
814                    MarkdownFlavor::Standard,
815                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
816                ),
817                (
818                    MarkdownFlavor::Hugo,
819                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
820                ),
821            ] {
822                for ending in ["\n", "\r\n"] {
823                    let source = format!("# Visible\n\n{code}\n\n### Next\n").replace('\n', ending);
824                    let expected = source.replace("### Next", "## Next");
825                    let ctx = LintContext::new(&source, flavor, None);
826                    let warnings = rule.check(&ctx).unwrap();
827                    assert_eq!(warnings.len(), 1, "{source}");
828                    assert_eq!(warnings[0].line, source.lines().count());
829                    assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
830                }
831            }
832        }
833    }
834
835    #[test]
836    fn test_literal_headings_do_not_initialize_or_reset_levels() {
837        let rule = MD001HeadingIncrement::default();
838        for literal in ["# Fake", "\nFake\n===\n"] {
839            let code = format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}");
840            for (prefix, next, target) in [
841                ("", "### Next", "### Next"),
842                ("## Visible\n\n", "#### Next", "### Next"),
843            ] {
844                let source = format!("{prefix}{code}\n\n{next}\n");
845                let expected = format!("{prefix}{code}\n\n{target}\n");
846                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
847                assert_eq!(rule.check(&ctx).unwrap().len(), usize::from(next != target), "{source}");
848                assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
849            }
850        }
851    }
852
853    #[test]
854    fn test_dynamic_and_mdx_body_level_jumps_remain_fixable() {
855        let rule = MD001HeadingIncrement::default();
856        for (flavor, source) in [
857            (crate::config::MarkdownFlavor::Standard, "# {{ title }}\n\n### Next\n"),
858            (
859                crate::config::MarkdownFlavor::MDX,
860                "<div>\n\n# Visible\n\n### Next\n\n</div>\n",
861            ),
862        ] {
863            let ctx = LintContext::new(source, flavor, None);
864            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
865            assert_eq!(rule.fix(&ctx).unwrap(), source.replace("### Next", "## Next"));
866        }
867    }
868    #[test]
869    fn test_atx_level_fixes_preserve_literal_titles() {
870        let rule = MD001HeadingIncrement::default();
871        for tail in [" ", "  ", "\t", " ###"] {
872            for (flavor, code) in [
873                (
874                    crate::config::MarkdownFlavor::Standard,
875                    format!("{{% set unused=\"préface{tail}\nlast\" %}}{{{{ unused|length }}}}"),
876                ),
877                (
878                    crate::config::MarkdownFlavor::Hugo,
879                    format!("{{{{< note title=`préface{tail}\nlast` >}}}}"),
880                ),
881            ] {
882                for ending in ["\n", "\r\n"] {
883                    let source = format!("# Visible 🐇\n\n  ### {code}\n").replace('\n', ending);
884                    let expected = source.replacen("  ### ", "  ## ", 1);
885                    let ctx = LintContext::new(&source, flavor, None);
886                    let warnings = rule.check(&ctx).unwrap();
887                    assert_eq!(warnings.len(), 1);
888                    let fix = warnings[0].fix.as_ref().unwrap();
889                    let start = source.find("### ").unwrap();
890                    assert_eq!(fix.range, start..start + 3);
891                    assert_eq!(fix.replacement, "##");
892                    assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
893                }
894            }
895        }
896    }
897
898    #[test]
899    fn test_atx_level_fixes_preserve_title_formatting() {
900        let rule = MD001HeadingIncrement::default();
901        for line in [
902            "### Title ###",
903            "###\tTitle\t###",
904            "  ###   Title  ###  ",
905            "### Title  ",
906            "### <span title=\"value  \">Text</span> ###",
907            "### `value  ` ###",
908        ] {
909            let source = format!("# Visible\n\n{line}\n");
910            let expected = source.replacen("###", "##", 1);
911            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
912            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
913        }
914        let source = "# Visible\n\n- Item\n\n  ### Title ###\n";
915        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
916        assert_eq!(rule.fix(&ctx).unwrap(), source.replacen("###", "##", 1));
917    }
918}