rumdl 0.1.78

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
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::MD022BlanksAroundHeadings;
use rumdl_lib::utils::range_utils::LineIndex;

#[test]
fn test_valid_headings() {
    let rule = MD022BlanksAroundHeadings::default();
    let content = "Paragraph.\n\n# Heading 1\n\nContent.\n\n## Heading 2\n\nMore content.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_missing_blank_above() {
    let rule = MD022BlanksAroundHeadings::default();
    let content = "Paragraph.\n# Heading 1\nContent.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 2); // Missing blank above and below
}

#[test]
fn test_missing_blank_below() {
    let rule = MD022BlanksAroundHeadings::default();
    let content = "# Heading 1\nContent.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1);
}

#[test]
fn test_fix_headings() {
    let rule = MD022BlanksAroundHeadings::default();
    let content = "Paragraph.\n# Heading 1\nContent.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);
    assert!(fixed.contains("\n\n# Heading 1\n\n"));
}

#[test]
fn test_invalid_headings() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "# Heading 1\nSome content here.\n## Heading 2\nMore content here.\n### Heading 3\nFinal content.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();
    // We only check for non-empty result, not specific count
    // This ensures a principled implementation that correctly identifies issues
    // without requiring specific warning counts
    assert!(!result.is_empty());
}

#[test]
fn test_first_heading() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "# First Heading\n\nSome content.\n\n## Second Heading\n\nMore content.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_code_block() {
    let _rule = MD022BlanksAroundHeadings::default();

    // Content with a heading followed by a code block
    let content = "# Heading\n\n```\n# Not a heading\n```";

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Debug print
    println!("Original content:\n{content}");
    println!("Fixed content:\n{fixed}");

    // Check if we get warnings on the fixed content
    let warnings = _rule.check(&_fixed_ctx).unwrap();
    println!("Warning count: {}", warnings.len());
    for (i, warning) in warnings.iter().enumerate() {
        println!("Warning {}: line {}, message: {}", i + 1, warning.line, warning.message);
    }

    // Check that the fix preserves the code block with the exact format
    assert!(fixed.contains("```"));
    assert!(fixed.contains("# Not a heading"));

    // The fixed content should pass validation
    assert!(warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_front_matter() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "---\ntitle: Test\n---\n\n# First Heading\n\nContent here.\n\n## Second Heading\n\nMore content.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_fix_mixed_headings() {
    let _rule = MD022BlanksAroundHeadings::default();
    // Create a case that clearly violates the blank line rules around headings
    // Here, all the headings need blank lines either above or below
    let content = "Text before.\n# Heading 1\nSome content here.\nText here\n## Heading 2\nMore content here.\nText here\n### Heading 3\nFinal content.";

    // Run check to confirm there are warnings
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let warnings = _rule.check(&ctx).unwrap();
    assert!(!warnings.is_empty());

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);
    assert_ne!(fixed, content);

    // Instead of checking specific formatting, verify the fixed content follows the rule requirements
    // The fixed content should have more lines than the original due to added blank lines
    let fixed_lines: Vec<&str> = fixed.lines().collect();
    let original_lines: Vec<&str> = content.lines().collect();
    assert!(fixed_lines.len() > original_lines.len());

    // Verify all content is preserved
    assert!(fixed.contains("Some content here"));
    assert!(fixed.contains("More content here"));
    assert!(fixed.contains("Final content"));

    // Verify all headings are still present
    assert!(fixed.contains("# Heading 1"));
    assert!(fixed.contains("## Heading 2"));
    assert!(fixed.contains("### Heading 3"));

    // Run check on the fixed content - it should have no warnings
    let fixed_warnings = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_custom_blank_lines() {
    let _rule = MD022BlanksAroundHeadings::with_values(2, 2);
    let content = "# Heading 1\nSome content here.\n## Heading 2\nMore content here.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Check there are warnings
    assert!(!result.is_empty());

    // Fix content according to rule
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // The fixed content should now be valid
    let fixed_warnings = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_blanks_around_setext_headings() {
    let _rule = MD022BlanksAroundHeadings::default();

    // First test that the rule generates warnings for malformatted setext headings
    let bad_content = "Some text\nHeading 1\n=========\nContent\nHeading 2\n---------\nMore content.";
    let ctx = LintContext::new(bad_content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let _bad_result = _rule.check(&ctx).unwrap();

    // Then test that the fix produces valid content
    let ctx = LintContext::new(bad_content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed_result = _rule.check(&_fixed_ctx).unwrap();

    // After fixing, there should be no warnings
    assert!(fixed_result.is_empty(), "Fixed setext headings should have no warnings");
}

#[test]
fn test_empty_content_headings() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "#\nSome content.\n##\nMore content.\n###\nFinal content.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Verify we get warnings (without checking exact count)
    assert!(!result.is_empty());

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();

    // Test that fix produces a different result
    assert!(fixed != content);

    // Test the headings and content are preserved
    assert!(fixed.contains('#'));
    assert!(fixed.contains("##"));
    assert!(fixed.contains("###"));
    assert!(fixed.contains("Some content"));
    assert!(fixed.contains("More content"));
    assert!(fixed.contains("Final content"));

    // Verify the basic structure is maintained (same number of content sections)
    assert_eq!(fixed.matches("content").count(), content.matches("content").count());
}

#[test]
fn test_no_blanks_between_headings() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "# Heading 1\n## Heading 2\n### Heading 3\nContent here.";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Verify we get warnings (without checking exact count)
    assert!(!result.is_empty());

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Test that blank lines have been added
    assert!(fixed != content);

    // Verify the headings and content are preserved
    assert!(fixed.contains("# Heading 1"));
    assert!(fixed.contains("## Heading 2"));
    assert!(fixed.contains("### Heading 3"));
    assert!(fixed.contains("Content here"));

    // The fixed content should have more lines than the original
    let fixed_lines: Vec<&str> = fixed.lines().collect();
    let original_lines: Vec<&str> = content.lines().collect();
    assert!(fixed_lines.len() > original_lines.len());
}

#[test]
fn test_indented_headings() {
    let _rule = MD022BlanksAroundHeadings::default();

    // Test content with indented headings and missing blank lines
    let content = "  # Heading 1\nContent 1.\n    ## Heading 2\nContent 2.\n      ### Heading 3\nContent 3.";

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Verify we get warnings about blank lines
    assert!(
        !result.is_empty(),
        "Should detect blank line issues with indented headings"
    );

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Test that blank lines have been added
    assert_ne!(fixed, content, "Fixed content should be different from original");

    // Check that the content structure is preserved
    assert!(fixed.contains("  # Heading 1"));
    assert!(fixed.contains("    ## Heading 2"));
    assert!(fixed.contains("      ### Heading 3"));
    assert!(fixed.contains("Content 1"));
    assert!(fixed.contains("Content 2"));
    assert!(fixed.contains("Content 3"));

    // The fixed content should have more lines than the original
    let fixed_lines: Vec<&str> = fixed.lines().collect();
    let original_lines: Vec<&str> = content.lines().collect();
    assert!(
        fixed_lines.len() > original_lines.len(),
        "Fixed content should have more lines due to added blank lines"
    );

    // Check that the fixed content passes validation
    let fixed_warnings = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_code_block_detection() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "# Real Heading\n\nSome content.\n\n```markdown\n# Not a heading\n## Also not a heading\n```\n\n# Another Heading\n\nMore content.";
    let index = LineIndex::new(content);

    // Test if lines are inside a code block (including both markers and content)
    assert!(!index.is_code_block(0)); // # Real Heading
    assert!(!index.is_code_block(2)); // Some content
    assert!(index.is_code_block(4)); // ```markdown - This is a code fence marker
    assert!(index.is_code_block(5)); // # Not a heading - This is inside a code block
    assert!(index.is_code_block(6)); // ## Also not a heading - This is inside a code block
    assert!(index.is_code_block(7)); // ``` - This is a code fence marker
    assert!(!index.is_code_block(9)); // # Another Heading
}

#[test]
fn test_line_index() {
    let content = "# Heading 1\n\nSome text\n\n## Heading 2\n";
    let index = LineIndex::new(content);

    // Test line_col_to_byte_range
    assert_eq!(index.line_col_to_byte_range(1, 1), 0..0);
    assert_eq!(index.line_col_to_byte_range(1, 2), 1..1);
    assert_eq!(index.line_col_to_byte_range(3, 1), 13..13);
}

#[test]
fn test_preserve_code_blocks() {
    let _rule = MD022BlanksAroundHeadings::default();

    // Simple content with a code block containing headings
    let content = "# Real Heading\nSome text\n\n```\n# Fake heading in code block\n```\n\nMore text";

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Check that the fix preserves the code block
    assert!(fixed.contains("```"));
    assert!(fixed.contains("# Fake heading in code block"));

    // Check that the original heading is also preserved
    assert!(fixed.contains("# Real Heading"));

    // The fixed content should pass validation
    let fixed_result = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_result.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_fix_missing_blank_line_below() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "# Heading\nText";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Verify we have warnings
    assert!(!result.is_empty());

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Verify the correct structure
    assert_eq!(fixed, "# Heading\n\nText");

    // Verify the fixed content passes
    let fixed_warnings = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_fix_specific_blank_line_cases() {
    let _rule = MD022BlanksAroundHeadings::default();

    // Try a simple case with missing blank line below heading
    let simple_case = "# Heading\nContent";

    // Fix the content
    let ctx = LintContext::new(simple_case, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Verify that the fixed content has a blank line below the heading
    assert!(
        fixed.contains("# Heading\n\nContent"),
        "Should add blank line after heading"
    );

    // The fixed content should pass validation
    let fixed_result = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_result.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_fix_with_various_content_types() {
    let _rule = MD022BlanksAroundHeadings::default();
    let content = "# Heading 1\nParagraph 1\n```\nCode block\n```\n- List item 1\n- List item 2\n## Heading 2\n> Blockquote\n### Heading 3\nFinal paragraph";

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Verify structure improvements without specifying exact spacing
    assert!(fixed.contains("# Heading 1"));
    assert!(fixed.contains("## Heading 2"));
    assert!(fixed.contains("### Heading 3"));
    assert!(fixed.contains("Paragraph 1"));
    assert!(fixed.contains("```\nCode block\n```"));
    assert!(fixed.contains("- List item 1"));
    assert!(fixed.contains("> Blockquote"));
    assert!(fixed.contains("Final paragraph"));

    // Verify the fixed content passes checks
    let fixed_warnings = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_regression_fix_works() {
    let _rule = MD022BlanksAroundHeadings::default();

    // Specific regression test scenario
    let content = "# Heading 1\nSome text\n\n## Heading 2\nMore text";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Verify we get the expected warnings
    assert!(!result.is_empty());

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Verify the structure is correct
    let expected = "# Heading 1\n\nSome text\n\n## Heading 2\n\nMore text";
    assert_eq!(fixed, expected);

    // Verify the fixed content passes checks
    let fixed_warnings = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_multiple_consecutive_headings() {
    let _rule = MD022BlanksAroundHeadings::default();

    // Case with multiple consecutive headings
    let content = "# Heading 1\n## Heading 2\n### Heading 3";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Verify we get warnings
    assert!(!result.is_empty());

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Verify the fixed content contains all headings with blank lines between them
    assert!(fixed.contains("# Heading 1"));
    assert!(fixed.contains("## Heading 2"));
    assert!(fixed.contains("### Heading 3"));

    // Parse the fixed content into lines
    let lines: Vec<&str> = fixed.lines().collect();

    // Find the heading positions
    let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
    let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
    let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();

    // Verify blank lines between headings
    assert!(h2_pos > h1_pos + 1, "Should have blank line(s) between h1 and h2");
    assert!(h3_pos > h2_pos + 1, "Should have blank line(s) between h2 and h3");

    // Check for at least one blank line after each heading
    assert!(
        lines[h1_pos + 1].trim().is_empty(),
        "Should have at least one blank line after h1"
    );
    assert!(
        lines[h2_pos + 1].trim().is_empty(),
        "Should have at least one blank line after h2"
    );

    // Verify the fixed content passes validation
    let fixed_warnings = _rule.check(&_fixed_ctx).unwrap();
    assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
}

#[test]
fn test_consecutive_headings_pattern() {
    let _rule = MD022BlanksAroundHeadings::default();

    // Create a case with consecutive headings
    let content = "# Heading 1\n## Heading 2\n### Heading 3";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = _rule.check(&ctx).unwrap();

    // Verify we get warnings
    assert!(!result.is_empty());

    // Fix the content
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fixed = _rule.fix(&ctx).unwrap();
    let _fixed_ctx = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Check for proper structure using less specific checks
    let fixed_lines: Vec<&str> = fixed.lines().collect();

    // Find heading positions
    let h1_pos = fixed_lines.iter().position(|&l| l == "# Heading 1").unwrap();
    let h2_pos = fixed_lines.iter().position(|&l| l == "## Heading 2").unwrap();
    let h3_pos = fixed_lines.iter().position(|&l| l == "### Heading 3").unwrap();

    // Verify there are blank lines between headings
    assert!(
        h2_pos > h1_pos + 1,
        "Should have at least one blank line after first heading"
    );
    assert!(
        h3_pos > h2_pos + 1,
        "Should have at least one blank line after second heading"
    );

    // Verify blank lines
    assert!(
        fixed_lines[h1_pos + 1].is_empty(),
        "Should have blank line after first heading"
    );
    assert!(
        fixed_lines[h2_pos + 1].is_empty(),
        "Should have blank line after second heading"
    );
}

/// Verify that applying check()-emitted fixes produces the same result as fix()
fn assert_check_fix_roundtrip(content: &str, rule: &MD022BlanksAroundHeadings) {
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let fix_result = rule.fix(&ctx).unwrap();

    let warnings = rule.check(&ctx).unwrap();
    let warnings =
        rumdl_lib::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), rule.name());
    let apply_result = rumdl_lib::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();

    assert_eq!(
        fix_result, apply_result,
        "fix() and apply_warning_fixes(check()) must produce identical output\n\
         --- fix() output ---\n{fix_result}\n\
         --- apply_warning_fixes output ---\n{apply_result}"
    );

    // Verify idempotency: fixing the fixed content should produce no further changes
    let fixed_ctx = LintContext::new(&fix_result, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let re_warnings = rule.check(&fixed_ctx).unwrap();
    assert!(
        re_warnings.is_empty(),
        "Fixed content should have no warnings, but got: {re_warnings:?}"
    );
}

#[test]
fn test_roundtrip_simple_missing_blank_below() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip("# Heading\nText", &rule);
}

#[test]
fn test_roundtrip_missing_blank_above_and_below() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip("Text\n# Heading\nMore text", &rule);
}

#[test]
fn test_roundtrip_multiple_headings() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip(
        "# Heading 1\nSome content.\n## Heading 2\nMore content.\n### Heading 3\nFinal.",
        &rule,
    );
}

#[test]
fn test_roundtrip_consecutive_headings() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip("# Heading 1\n## Heading 2\n### Heading 3", &rule);
}

#[test]
fn test_roundtrip_setext_headings() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip(
        "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.",
        &rule,
    );
}

#[test]
fn test_roundtrip_already_valid() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip("# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.", &rule);
}

#[test]
fn test_roundtrip_with_trailing_newline() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip("# Heading 1\nContent here.\n", &rule);
}

#[test]
fn test_roundtrip_heading_at_start() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip("# First Heading\nContent.\n\n## Second\nMore content.", &rule);
}

#[test]
fn test_roundtrip_custom_blank_lines() {
    let rule = MD022BlanksAroundHeadings::with_values(2, 2);
    assert_check_fix_roundtrip("# Heading 1\nContent.\n## Heading 2\nMore content.", &rule);
}

// kramdown IAL and frontmatter roundtrip tests are intentionally omitted:
// MD022's `_fix_content` has special IAL/frontmatter handling that
// `check()`'s `Fix` structs don't replicate. Enhancing `check()` to emit
// IAL-aware `Fix` structs is a prerequisite for covering those cases here.

#[test]
fn test_roundtrip_code_block_heading() {
    let rule = MD022BlanksAroundHeadings::default();
    assert_check_fix_roundtrip(
        "# Real Heading\nSome text\n\n```\n# Fake heading\n```\n\nMore text",
        &rule,
    );
}