rumdl 0.1.51

A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
use crate::HeadingStyle;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rules::front_matter_utils::FrontMatterUtils;
use crate::rules::heading_utils::HeadingUtils;
use crate::utils::range_utils::calculate_heading_range;
use regex::Regex;

/// Rule MD001: Heading levels should only increment by one level at a time
///
/// See [docs/md001.md](../../docs/md001.md) for full documentation, configuration, and examples.
///
/// This rule enforces a fundamental principle of document structure: heading levels
/// should increase by exactly one level at a time to maintain a proper document hierarchy.
///
/// ## Purpose
///
/// Proper heading structure creates a logical document outline and improves:
/// - Readability for humans
/// - Accessibility for screen readers
/// - Navigation in rendered documents
/// - Automatic generation of tables of contents
///
/// ## Examples
///
/// ### Correct Heading Structure
/// ```markdown
/// # Heading 1
/// ## Heading 2
/// ### Heading 3
/// ## Another Heading 2
/// ```
///
/// ### Incorrect Heading Structure
/// ```markdown
/// # Heading 1
/// ### Heading 3 (skips level 2)
/// #### Heading 4
/// ```
///
/// ## Behavior
///
/// This rule:
/// - Tracks the heading level throughout the document
/// - Validates that each new heading is at most one level deeper than the previous heading
/// - Allows heading levels to decrease by any amount (e.g., going from ### to #)
/// - Works with both ATX (`#`) and Setext (underlined) heading styles
///
/// ## Fix Behavior
///
/// When applying automatic fixes, this rule:
/// - Changes the level of non-compliant headings to be one level deeper than the previous heading
/// - Preserves the original heading style (ATX or Setext)
/// - Maintains indentation and other formatting
///
/// ## Rationale
///
/// Skipping heading levels (e.g., from `h1` to `h3`) can confuse readers and screen readers
/// by creating gaps in the document structure. Consistent heading increments create a proper
/// hierarchical outline essential for well-structured documents.
///
/// ## Front Matter Title Support
///
/// When `front_matter_title` is enabled (default: true), this rule recognizes a `title:` field
/// in YAML/TOML frontmatter as an implicit level-1 heading. This allows documents like:
///
/// ```markdown
/// ---
/// title: My Document
/// ---
///
/// ## First Section
/// ```
///
/// Without triggering a warning about skipping from H1 to H2, since the frontmatter title
/// counts as the H1.
///
#[derive(Debug, Clone)]
pub struct MD001HeadingIncrement {
    /// Whether to treat frontmatter title field as an implicit H1
    pub front_matter_title: bool,
    /// Optional regex pattern to match custom title fields in frontmatter
    pub front_matter_title_pattern: Option<Regex>,
}

impl Default for MD001HeadingIncrement {
    fn default() -> Self {
        Self {
            front_matter_title: true,
            front_matter_title_pattern: None,
        }
    }
}

/// Result of computing the fix for a single heading
struct HeadingFixInfo {
    /// The level after fixing (may equal original if no fix needed)
    fixed_level: usize,
    /// The heading style to use for the replacement
    style: HeadingStyle,
    /// Whether this heading needs a fix
    needs_fix: bool,
}

impl MD001HeadingIncrement {
    /// Create a new instance with specified settings
    pub fn new(front_matter_title: bool) -> Self {
        Self {
            front_matter_title,
            front_matter_title_pattern: None,
        }
    }

    /// Create a new instance with a custom pattern for matching title fields
    pub fn with_pattern(front_matter_title: bool, pattern: Option<String>) -> Self {
        let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
            Ok(regex) => Some(regex),
            Err(e) => {
                log::warn!("Invalid front_matter_title_pattern regex for MD001: {e}");
                None
            }
        });

        Self {
            front_matter_title,
            front_matter_title_pattern,
        }
    }

    /// Check if the document has a front matter title field
    fn has_front_matter_title(&self, content: &str) -> bool {
        if !self.front_matter_title {
            return false;
        }

        // If we have a custom pattern, use it to search front matter content
        if let Some(ref pattern) = self.front_matter_title_pattern {
            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
            for line in front_matter_lines {
                if pattern.is_match(line) {
                    return true;
                }
            }
            return false;
        }

        // Default behavior: check for "title:" field
        FrontMatterUtils::has_front_matter_field(content, "title:")
    }

    /// Single source of truth for heading level computation and style mapping.
    ///
    /// Returns `(HeadingFixInfo, new_prev_level)`. Both `check()` and `fix()` call
    /// this, making it structurally impossible for them to diverge.
    fn compute_heading_fix(
        prev_level: Option<usize>,
        heading: &crate::lint_context::HeadingInfo,
    ) -> (HeadingFixInfo, Option<usize>) {
        let level = heading.level as usize;

        let (fixed_level, needs_fix) = if let Some(prev) = prev_level
            && level > prev + 1
        {
            (prev + 1, true)
        } else {
            (level, false)
        };

        // Map heading style, adjusting Setext variant based on the fixed level
        let style = match heading.style {
            crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
                if fixed_level == 1 {
                    HeadingStyle::Setext1
                } else {
                    HeadingStyle::Setext2
                }
            }
        };

        let info = HeadingFixInfo {
            fixed_level,
            style,
            needs_fix,
        };
        (info, Some(fixed_level))
    }
}

impl Rule for MD001HeadingIncrement {
    fn name(&self) -> &'static str {
        "MD001"
    }

    fn description(&self) -> &'static str {
        "Heading levels should only increment by one level at a time"
    }

    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
        let mut warnings = Vec::new();

        let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
            Some(1)
        } else {
            None
        };

        for valid_heading in ctx.valid_headings() {
            let heading = valid_heading.heading;
            let line_info = valid_heading.line_info;

            let level = heading.level as usize;

            let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
            prev_level = new_prev;

            if fix_info.needs_fix {
                let line_content = line_info.content(ctx.content);
                let original_indent = &line_content[..line_info.indent];
                let replacement =
                    HeadingUtils::convert_heading_style(&heading.raw_text, fix_info.fixed_level as u32, fix_info.style);

                let (start_line, start_col, end_line, end_col) =
                    calculate_heading_range(valid_heading.line_num, line_content);

                warnings.push(LintWarning {
                    rule_name: Some(self.name().to_string()),
                    line: start_line,
                    column: start_col,
                    end_line,
                    end_column: end_col,
                    message: format!(
                        "Expected heading level {}, but found heading level {}",
                        fix_info.fixed_level, level
                    ),
                    severity: Severity::Error,
                    fix: Some(Fix {
                        range: ctx.line_index.line_content_range(valid_heading.line_num),
                        replacement: format!("{original_indent}{replacement}"),
                    }),
                });
            }
        }

        Ok(warnings)
    }

    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
        let mut fixed_lines = Vec::new();

        let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
            Some(1)
        } else {
            None
        };

        let mut skip_next = false;
        for (i, line_info) in ctx.lines.iter().enumerate() {
            let line_num = i + 1;
            if skip_next {
                skip_next = false;
                continue;
            }

            if let Some(heading) = line_info.heading.as_deref() {
                if !heading.is_valid {
                    fixed_lines.push(line_info.content(ctx.content).to_string());
                    continue;
                }

                // If rule is disabled for this line, keep original and skip setext underline
                if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
                    fixed_lines.push(line_info.content(ctx.content).to_string());
                    let is_setext = matches!(
                        heading.style,
                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
                    );
                    if is_setext {
                        skip_next = true;
                    }
                    // Still update prev_level so subsequent headings are computed correctly
                    prev_level = Some(heading.level as usize);
                    continue;
                }

                let is_setext = matches!(
                    heading.style,
                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
                );

                let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
                prev_level = new_prev;

                if fix_info.needs_fix {
                    let replacement = HeadingUtils::convert_heading_style(
                        &heading.raw_text,
                        fix_info.fixed_level as u32,
                        fix_info.style,
                    );
                    let line = line_info.content(ctx.content);
                    let original_indent = &line[..line_info.indent];
                    fixed_lines.push(format!("{original_indent}{replacement}"));

                    // Setext headings span two lines (text + underline). The replacement
                    // already includes both lines, so skip the underline line.
                    if is_setext {
                        skip_next = true;
                    }
                } else {
                    // Heading is valid — preserve original content exactly
                    fixed_lines.push(line_info.content(ctx.content).to_string());
                }
            } else {
                fixed_lines.push(line_info.content(ctx.content).to_string());
            }
        }

        let mut result = fixed_lines.join("\n");
        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
            result.push('\n');
        }
        Ok(result)
    }

    fn category(&self) -> RuleCategory {
        RuleCategory::Heading
    }

    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
        // Fast path: check if document likely has headings
        if ctx.content.is_empty() || !ctx.likely_has_headings() {
            return true;
        }
        // Verify valid headings actually exist
        !ctx.has_valid_headings()
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
    where
        Self: Sized,
    {
        // Get MD001 config section
        let (front_matter_title, front_matter_title_pattern) = if let Some(rule_config) = config.rules.get("MD001") {
            let fmt = rule_config
                .values
                .get("front-matter-title")
                .or_else(|| rule_config.values.get("front_matter_title"))
                .and_then(|v| v.as_bool())
                .unwrap_or(true);

            let pattern = rule_config
                .values
                .get("front-matter-title-pattern")
                .or_else(|| rule_config.values.get("front_matter_title_pattern"))
                .and_then(|v| v.as_str())
                .filter(|s: &&str| !s.is_empty())
                .map(String::from);

            (fmt, pattern)
        } else {
            (true, None)
        };

        Box::new(MD001HeadingIncrement::with_pattern(
            front_matter_title,
            front_matter_title_pattern,
        ))
    }

    fn default_config_section(&self) -> Option<(String, toml::Value)> {
        Some((
            "MD001".to_string(),
            toml::toml! {
                front-matter-title = true
            }
            .into(),
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lint_context::LintContext;

    #[test]
    fn test_basic_functionality() {
        let rule = MD001HeadingIncrement::default();

        // Test with valid headings
        let content = "# Heading 1\n## Heading 2\n### Heading 3";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(result.is_empty());

        // Test with invalid headings: H1 → H3 → H4
        // H3 skips level 2, and H4 is > fixed(H3=H2) + 1, so both are flagged
        let content = "# Heading 1\n### Heading 3\n#### Heading 4";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].line, 2);
        assert_eq!(result[1].line, 3);
    }

    #[test]
    fn test_frontmatter_title_counts_as_h1() {
        let rule = MD001HeadingIncrement::default();

        // Frontmatter with title, followed by H2 - should pass
        let content = "---\ntitle: My Document\n---\n\n## First Section";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(
            result.is_empty(),
            "H2 after frontmatter title should not trigger warning"
        );

        // Frontmatter with title, followed by H3 - should warn (skips H2)
        let content = "---\ntitle: My Document\n---\n\n### Third Level";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
        assert!(result[0].message.contains("Expected heading level 2"));
    }

    #[test]
    fn test_frontmatter_without_title() {
        let rule = MD001HeadingIncrement::default();

        // Frontmatter without title, followed by H2 - first heading has no predecessor
        // so it should pass (no increment check for the first heading)
        let content = "---\nauthor: John\n---\n\n## First Section";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(
            result.is_empty(),
            "First heading after frontmatter without title has no predecessor"
        );
    }

    #[test]
    fn test_frontmatter_title_disabled() {
        let rule = MD001HeadingIncrement::new(false);

        // Frontmatter with title, but feature disabled - H2 has no predecessor
        let content = "---\ntitle: My Document\n---\n\n## First Section";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(
            result.is_empty(),
            "With front_matter_title disabled, first heading has no predecessor"
        );
    }

    #[test]
    fn test_frontmatter_title_with_subsequent_headings() {
        let rule = MD001HeadingIncrement::default();

        // Complete document with frontmatter title
        let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(result.is_empty(), "Valid heading progression after frontmatter title");
    }

    #[test]
    fn test_frontmatter_title_fix() {
        let rule = MD001HeadingIncrement::default();

        // Frontmatter with title, H3 should be fixed to H2
        let content = "---\ntitle: My Document\n---\n\n### Third Level";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert!(
            fixed.contains("## Third Level"),
            "H3 should be fixed to H2 when frontmatter has title"
        );
    }

    #[test]
    fn test_toml_frontmatter_title() {
        let rule = MD001HeadingIncrement::default();

        // TOML frontmatter with title
        let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(result.is_empty(), "TOML frontmatter title should count as H1");
    }

    #[test]
    fn test_no_frontmatter_no_h1() {
        let rule = MD001HeadingIncrement::default();

        // No frontmatter, starts with H2 - first heading has no predecessor, so no warning
        let content = "## First Section\n\n### Subsection";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(
            result.is_empty(),
            "First heading (even if H2) has no predecessor to compare against"
        );
    }

    #[test]
    fn test_fix_preserves_attribute_lists() {
        let rule = MD001HeadingIncrement::default();

        // H1 followed by H3 with attribute list - fix should preserve { #custom-id }
        let content = "# Heading 1\n\n### Heading 3 { #custom-id .special }";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);

        // Verify fix() preserves attribute list
        let fixed = rule.fix(&ctx).unwrap();
        assert!(
            fixed.contains("## Heading 3 { #custom-id .special }"),
            "fix() should preserve attribute list, got: {fixed}"
        );

        // Verify check() fix output also preserves attribute list
        let warnings = rule.check(&ctx).unwrap();
        assert_eq!(warnings.len(), 1);
        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
        assert!(
            fix.replacement.contains("{ #custom-id .special }"),
            "check() fix should preserve attribute list, got: {}",
            fix.replacement
        );
    }

    #[test]
    fn test_check_single_skip_with_repeated_level() {
        let rule = MD001HeadingIncrement::default();

        // H1 followed by two H3s: only the first H3 is flagged.
        // After fixing H3a to H2 (prev+1), H3b at level 3 = 2+1 is valid.
        let content = "# H1\n### H3a\n### H3b";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);

        let warnings = rule.check(&ctx).unwrap();
        assert_eq!(warnings.len(), 1, "Only first H3 should be flagged: got {warnings:?}");
        assert!(warnings[0].message.contains("Expected heading level 2"));

        // Verify check()+apply_all_fixes produces idempotent output
        let fixed = rule.fix(&ctx).unwrap();
        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
        let warnings_after = rule.check(&ctx_fixed).unwrap();
        assert!(
            warnings_after.is_empty(),
            "After fix, no warnings should remain: {fixed:?}, warnings: {warnings_after:?}"
        );
    }

    #[test]
    fn test_check_cascading_skip_produces_idempotent_fix() {
        let rule = MD001HeadingIncrement::default();

        // H1 → H4 → H5: both are flagged.
        // H4: prev=1, expected=2. Fixed level tracked as 2.
        // H5: prev=2, expected=3.
        // Both fixes applied in one pass produce clean output.
        let content = "# Title\n#### Deep\n##### Deeper";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);

        let warnings = rule.check(&ctx).unwrap();
        assert_eq!(
            warnings.len(),
            2,
            "Both deep headings should be flagged for idempotent fix"
        );
        assert!(warnings[0].message.contains("Expected heading level 2"));
        assert!(warnings[1].message.contains("Expected heading level 3"));

        // Verify single-pass idempotent fix
        let fixed = rule.fix(&ctx).unwrap();
        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
        let warnings_after = rule.check(&ctx_fixed).unwrap();
        assert!(
            warnings_after.is_empty(),
            "Fixed content should have no warnings: {fixed:?}"
        );
    }

    #[test]
    fn test_check_level_decrease_resets_tracking() {
        let rule = MD001HeadingIncrement::default();

        // H1 → H3 (flagged) → H1 (decrease, always allowed) → H3 (flagged again)
        let content = "# Title\n### Sub\n# Another\n### Sub2";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);

        let warnings = rule.check(&ctx).unwrap();
        assert_eq!(
            warnings.len(),
            2,
            "Both H3 headings should be flagged (each follows an H1)"
        );

        // Verify single-pass idempotent fix
        let fixed = rule.fix(&ctx).unwrap();
        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
        assert!(
            rule.check(&ctx_fixed).unwrap().is_empty(),
            "Fixed content should pass: {fixed:?}"
        );
    }

    /// Core invariant: for every warning with a Fix, the replacement text must
    /// match what fix() produces for that same line.
    #[test]
    fn test_check_and_fix_produce_identical_replacements() {
        let rule = MD001HeadingIncrement::default();

        let inputs = [
            "# H1\n### H3\n",
            "# H1\n#### H4\n##### H5\n",
            "# H1\n### H3\n# H1b\n### H3b\n",
            "# H1\n\n### H3 { #custom-id }\n",
            "---\ntitle: Doc\n---\n\n### Deep\n",
        ];

        for input in &inputs {
            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
            let warnings = rule.check(&ctx).unwrap();
            let fixed = rule.fix(&ctx).unwrap();
            let fixed_lines: Vec<&str> = fixed.lines().collect();

            for warning in &warnings {
                if let Some(ref fix) = warning.fix {
                    // Extract the fixed line from fix() output for the same line number
                    let line_idx = warning.line - 1;
                    assert!(
                        line_idx < fixed_lines.len(),
                        "Warning line {} out of range for fixed output (input: {input:?})",
                        warning.line,
                    );
                    let fix_output_line = fixed_lines[line_idx];
                    assert_eq!(
                        fix.replacement, fix_output_line,
                        "check() fix and fix() output diverge at line {} (input: {input:?})",
                        warning.line,
                    );
                }
            }
        }
    }

    /// Setext H1 followed by deep ATX heading: Setext heading is untouched,
    /// ATX heading is fixed to H2.
    #[test]
    fn test_setext_headings_mixed_with_atx_cascading() {
        let rule = MD001HeadingIncrement::default();

        let content = "Setext Title\n============\n\n#### Deep ATX\n";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);

        let warnings = rule.check(&ctx).unwrap();
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].message.contains("Expected heading level 2"));

        let fixed = rule.fix(&ctx).unwrap();
        assert!(
            fixed.contains("## Deep ATX"),
            "H4 after Setext H1 should be fixed to ATX H2, got: {fixed}"
        );

        // Verify idempotency
        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
        assert!(
            rule.check(&ctx_fixed).unwrap().is_empty(),
            "Fixed content should produce no warnings"
        );
    }

    /// fix(fix(x)) == fix(x) for various inputs
    #[test]
    fn test_fix_idempotent_applied_twice() {
        let rule = MD001HeadingIncrement::default();

        let inputs = [
            "# H1\n### H3\n#### H4\n",
            "## H2\n##### H5\n###### H6\n",
            "# A\n### B\n# C\n### D\n##### E\n",
            "# H1\nH2\n--\n#### H4\n",
            // Setext edge cases
            "Title\n=====\n",
            "Title\n=====\n\n#### Deep\n",
            "Sub\n---\n\n#### Deep\n",
            "T1\n==\nT2\n--\n#### Deep\n",
        ];

        for input in &inputs {
            let ctx1 = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
            let fixed_once = rule.fix(&ctx1).unwrap();

            let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
            let fixed_twice = rule.fix(&ctx2).unwrap();

            assert_eq!(
                fixed_once, fixed_twice,
                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed_once:?}\nsecond: {fixed_twice:?}"
            );
        }
    }

    /// Setext underline must not be duplicated: fix() should produce the same
    /// number of lines as the input for valid documents.
    #[test]
    fn test_setext_fix_no_underline_duplication() {
        let rule = MD001HeadingIncrement::default();

        // Setext H1 only — no fix needed, output must be identical
        let content = "Title\n=====\n";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(fixed, content, "Valid Setext H1 should be unchanged");

        // Setext H2 only — no fix needed
        let content = "Sub\n---\n";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(fixed, content, "Valid Setext H2 should be unchanged");

        // Two consecutive Setext headings — valid H1 then H2
        let content = "Title\n=====\nSub\n---\n";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(fixed, content, "Valid consecutive Setext headings should be unchanged");

        // Setext H1 at end of file without trailing newline
        let content = "Title\n=====";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(fixed, content, "Setext H1 at EOF without newline should be unchanged");

        // Setext H2 followed by deep ATX heading
        let content = "Sub\n---\n\n#### Deep\n";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert!(
            fixed.contains("### Deep"),
            "H4 after Setext H2 should become H3, got: {fixed}"
        );
        assert_eq!(
            fixed.matches("---").count(),
            1,
            "Underline should not be duplicated, got: {fixed}"
        );

        // Underline longer than text must not be normalized for valid headings
        let content = "Hi\n==========\n";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(
            fixed, content,
            "Valid Setext with long underline must be preserved exactly, got: {fixed}"
        );

        // Underline shorter than text must not be normalized
        let content = "Long Title Here\n===\n";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(
            fixed, content,
            "Valid Setext with short underline must be preserved exactly, got: {fixed}"
        );
    }
}