rumdl 0.1.79

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
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::MD005ListIndent;

#[test]
fn test_valid_unordered_list() {
    let rule = MD005ListIndent::default();
    let content = "\
* Item 1
* Item 2
  * Nested 1
  * Nested 2
* Item 3";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_valid_ordered_list() {
    let rule = MD005ListIndent::default();
    let content = "\
1. Item 1
2. Item 2
   1. Nested 1
   2. Nested 2
3. Item 3";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // With dynamic alignment, nested items should align with parent's text content
    // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
    assert!(result.is_empty());
}

#[test]
fn test_frontmatter_yaml_lists_not_detected() {
    // Test for issue #35 - YAML lists in frontmatter should not be detected as Markdown lists
    let rule = MD005ListIndent::default();
    let content = "\
---
layout: post
title: \"title\"
creator:
  - 'user1'
  - 'user2'
creator_num:
  - 1253217
  - 1615089
tags: [tag1, tag2, tag3]
---

# TITLE

## Heading

Whatever

And a list:

- Item1
- Item2";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // Should not flag YAML lists in frontmatter
    assert!(result.is_empty(), "MD005 should not check lists in frontmatter");
}

#[test]
fn test_invalid_unordered_indent() {
    let rule = MD005ListIndent::default();
    let content = "\
* Item 1
 * Item 2
   * Nested 1";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // Dynamic detection: line 2 has 1 space, treated as top-level with wrong indent
    assert_eq!(result.len(), 1);
    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "* Item 1\n* Item 2\n   * Nested 1");
}

#[test]
fn test_invalid_ordered_indent() {
    let rule = MD005ListIndent::default();
    let content = "\
1. Item 1
 2. Item 2
    1. Nested 1";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1);
    let fixed = rule.fix(&ctx).unwrap();
    // Dynamic detection: line 2 has 1 space, treated as top-level with wrong indent
    assert_eq!(fixed, "1. Item 1\n2. Item 2\n    1. Nested 1");
}

#[test]
fn test_mixed_list_types() {
    let rule = MD005ListIndent::default();
    let content = "\
* Item 1
  1. Nested ordered
  * Nested unordered
* Item 2";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_multiple_levels() {
    let rule = MD005ListIndent::default();
    let content = "\
* Level 1
   * Level 2
      * Level 3";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // Dynamic detection accepts 3-space pattern
    assert_eq!(result.len(), 0, "Should accept consistent 3-space indentation");
    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, content, "No changes needed for consistent indentation");
}

#[test]
fn test_empty_lines() {
    let rule = MD005ListIndent::default();
    let content = "\
* Item 1

  * Nested 1

* Item 2";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_no_lists() {
    let rule = MD005ListIndent::default();
    let content = "\
Just some text
More text
Even more text";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_complex_nesting() {
    let rule = MD005ListIndent::default();
    let content = "\
* Level 1
  * Level 2
    * Level 3
  * Back to 2
    1. Ordered 3
    2. Still 3
* Back to 1";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_invalid_complex_nesting() {
    let rule = MD005ListIndent::default();
    let content = "\
* Level 1
   * Level 2
     * Level 3
   * Back to 2
      1. Ordered 3
     2. Still 3
* Back to 1";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // MD005 groups items by (parent_content_column, is_ordered) to prevent oscillation with MD007.
    // Level 3 ordered items (lines 5-6) are checked separately from level 3 unordered (line 3).
    // First-established for ordered group = 6 spaces (from line 5), so line 6 (5 spaces) is flagged.
    assert_eq!(
        result.len(),
        1,
        "Should flag line 6 which is inconsistent with other ordered items"
    );
    let fixed = rule.fix(&ctx).unwrap();
    // Line 6 is fixed to match line 5's indentation (6 spaces)
    assert_eq!(
        fixed,
        "* Level 1\n   * Level 2\n     * Level 3\n   * Back to 2\n      1. Ordered 3\n      2. Still 3\n* Back to 1"
    );
}

// ============================================================================
// Tab-indented list detection tests (issue #254)
//
// Issue #254: Tab-indented nested lists were not detected because pulldown-cmark
// reports item events at the newline position before the tab, not at the tab itself.
// This caused MD004, MD005, MD007 to miss nested items entirely.
// ============================================================================

/// Regression test: Tab-indented nested lists must be detected.
///
/// Before the fix, pulldown-cmark reported the nested item at byte 8 (the newline),
/// which mapped to line 1 instead of line 2. The fix detects this and advances
/// to the correct line.
#[test]
fn test_tab_indented_list_detection_regression() {
    let content = "* Item 1\n\t- Nested with tab";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Both items MUST be detected - this was broken before the fix
    let detected: Vec<_> = ctx
        .lines
        .iter()
        .enumerate()
        .filter_map(|(i, l)| l.list_item.as_ref().map(|item| (i + 1, &item.marker)))
        .collect();

    assert_eq!(
        detected.len(),
        2,
        "Regression: Both list items must be detected. Got: {detected:?}"
    );
    assert_eq!(detected[0], (1, &"*".to_string()), "Line 1 should have '*' marker");
    assert_eq!(detected[1], (2, &"-".to_string()), "Line 2 should have '-' marker");
}

/// Verify that rules actually WORK with tab-indented lists, not just detection.
#[test]
fn test_tab_indented_list_rules_work() {
    use rumdl_lib::rules::MD004UnorderedListStyle;

    // MD004 should detect inconsistent markers in tab-indented lists
    let rule = MD004UnorderedListStyle::default();
    let content = "- Item 1\n\t* Nested with wrong marker";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(
        warnings.len(),
        1,
        "MD004 should detect wrong marker on tab-indented nested item"
    );
    assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
}

#[test]
fn test_tab_indented_nested_lists() {
    // Tab indentation should be detected correctly
    let content = "* Item 1\n\t- Nested with tab\n\t\t+ Double tab nested";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // All 3 items should be detected as list items
    let list_item_count = ctx.lines.iter().filter(|l| l.list_item.is_some()).count();
    assert_eq!(list_item_count, 3, "All 3 tab-indented list items should be detected");
}

#[test]
fn test_mixed_tab_and_space_indentation() {
    // Mixed tab/space indentation should be detected
    let content = "- Item 1\n  - Space nested\n\t- Tab nested";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let list_item_count = ctx.lines.iter().filter(|l| l.list_item.is_some()).count();
    assert_eq!(
        list_item_count, 3,
        "Mixed tab/space nested lists should all be detected"
    );
}

#[test]
fn test_toml_frontmatter_lists_not_detected() {
    // TOML frontmatter lists should not be detected as Markdown lists
    let content = r#"+++
title = "Test"
tags = ["tag1", "tag2"]
[[items]]
name = "item1"
[[items]]
name = "item2"
+++

# Heading

- Actual list item"#;
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Only the actual Markdown list should be detected
    let list_lines: Vec<_> = ctx
        .lines
        .iter()
        .enumerate()
        .filter(|(_, l)| l.list_item.is_some())
        .collect();

    assert_eq!(
        list_lines.len(),
        1,
        "Only the Markdown list after frontmatter should be detected"
    );
}

#[test]
fn test_blockquote_with_tab_indented_list() {
    // Blockquoted lists with tab indentation
    let content = "> - Item 1\n>\t- Tab nested in blockquote";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let list_item_count = ctx.lines.iter().filter(|l| l.list_item.is_some()).count();
    assert_eq!(list_item_count, 2, "Blockquoted tab-indented lists should be detected");
}

#[test]
fn test_deeply_indented_content_not_list() {
    // Lines with 8+ spaces of indentation should be code blocks, not lists
    // Per CommonMark, 4+ spaces of indentation (after accounting for list context) creates a code block
    let content = "- Item 1\n        - 8 spaces should be code block";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // Only the first item should be detected as a list item
    // The second line is treated as an indented code block per CommonMark
    let list_item_count = ctx.lines.iter().filter(|l| l.list_item.is_some()).count();
    assert_eq!(
        list_item_count, 1,
        "8+ space indentation should be treated as code block, not list"
    );
}

// ============================================================================
// MD005/MD007 oscillation prevention tests
//
// MD005 groups items by (parent_content_column, is_ordered), treating ordered and
// unordered lists as separate concerns for indentation consistency. This prevents
// oscillation where MD007 fixes bullet indent and MD005 reverts it as "inconsistent"
// with ordered items at the same level.
// ============================================================================

/// MD005 should NOT flag bullets as inconsistent with ordered items at the same
/// level - they're separate semantic constructs.
#[test]
fn test_ordered_and_unordered_in_separate_groups() {
    use rumdl_lib::rules::MD007ULIndent;

    let md005 = MD005ListIndent::default();
    let md007 = MD007ULIndent::default();

    // Minimal reproduction: ordered item at one indent, bullet at different indent
    // Under same parent - MD005 should not consider them "inconsistent"
    let content = "* Parent\n  1. ordered at 2 spaces\n   - bullet at 3 spaces";

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // MD005 should NOT flag bullet because ordered/unordered are separate groups
    let md005_warnings = md005.check(&ctx).unwrap();
    assert!(
        md005_warnings.is_empty(),
        "MD005 should not flag bullet as inconsistent with ordered item - separate groups. Got: {md005_warnings:?}"
    );

    // MD007 may or may not flag the bullet (depends on expected indent)
    // The key test is that after any fix, we don't oscillate
    let md007_warnings = md007.check(&ctx).unwrap();

    if !md007_warnings.is_empty() {
        // Apply MD007 fix
        let fixed = md007.fix(&ctx).unwrap();
        let ctx_fixed = LintContext::new(&fixed, rumdl_lib::config::MarkdownFlavor::Standard, None);

        // After MD007 fix, MD005 should still be happy (no oscillation)
        let md005_after = md005.check(&ctx_fixed).unwrap();
        assert!(
            md005_after.is_empty(),
            "MD005 should not try to revert MD007's fix - no oscillation. Got: {md005_after:?}"
        );
    }
}

/// Bullets stay separate from ordered items regardless of which comes first.
#[test]
fn test_bullet_before_ordered_in_separate_groups() {
    let md005 = MD005ListIndent::default();

    // Bullet first, then ordered - also separate groups
    let content = "* Parent\n  - bullet at 2 spaces\n   1. ordered at 3 spaces";

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // MD005 should NOT flag ordered item as inconsistent with bullet
    let md005_warnings = md005.check(&ctx).unwrap();
    assert!(
        md005_warnings.is_empty(),
        "MD005 should not flag ordered as inconsistent with bullet - separate groups. Got: {md005_warnings:?}"
    );
}

/// Multiple ordered/unordered siblings at same level stay in separate groups.
#[test]
fn test_multiple_mixed_siblings_in_separate_groups() {
    let md005 = MD005ListIndent::default();

    // Multiple ordered items, then multiple bullets at different indent
    let content = "* Parent\n  1. First ordered\n  2. Second ordered\n   - First bullet\n   - Second bullet";

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    // MD005 should not flag anything - ordered items are consistent (2 spaces),
    // bullets are consistent (3 spaces), and they're in separate groups
    let md005_warnings = md005.check(&ctx).unwrap();
    assert!(
        md005_warnings.is_empty(),
        "MD005 should not flag - each type is internally consistent. Got: {md005_warnings:?}"
    );
}

/// MD005 still flags inconsistency within same list type.
#[test]
fn test_inconsistency_within_same_type_detected() {
    let md005 = MD005ListIndent::default();

    // Two bullets at different indents under same parent - SHOULD be flagged
    let content = "* Parent\n  - First bullet at 2 spaces\n   - Second bullet at 3 spaces";

    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let md005_warnings = md005.check(&ctx).unwrap();
    assert!(
        !md005_warnings.is_empty(),
        "MD005 should flag inconsistent bullets (same type, different indents). Got: {md005_warnings:?}"
    );
}

/// List items inside footnote definitions should not trigger MD005.
/// The 4-space indentation is required by footnote syntax, not a list indent issue.
#[test]
fn test_list_in_footnote_definition_not_flagged() {
    let md005 = MD005ListIndent::default();
    let content = "\
# Test

Text.[^note]

[^note]:
    - First item
    - Second item
      - Nested item
";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let warnings = md005.check(&ctx).unwrap();
    assert!(
        warnings.is_empty(),
        "MD005 should not flag list items inside footnote definitions: {warnings:?}"
    );
}

#[test]
fn test_math_block_not_flagged_as_list() {
    // Lines starting with - inside $$ ... $$ math blocks are math operators, not list items.
    // MD005 must not flag them for inconsistent indentation.
    let md005 = MD005ListIndent::default();
    let content = "\
# Example math

$$
- \\operatorname{Re} \\frac{L'(s, \\chi)}{L(s, \\chi)}
  + \\frac{1}{2} \\log\\frac{q}{\\pi}
$$
";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let warnings = md005.check(&ctx).unwrap();
    assert!(
        warnings.is_empty(),
        "MD005 should not flag math operators inside $$ blocks: {warnings:?}"
    );
}

#[test]
fn test_math_block_multiline_with_indented_lines() {
    // Indented continuation lines in a math block should not be flagged by MD005.
    let md005 = MD005ListIndent::default();
    let content = "\
# Math

$$
- a
  - b
    - c
$$
";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let warnings = md005.check(&ctx).unwrap();
    assert!(
        warnings.is_empty(),
        "MD005 should not flag indented lines inside math blocks: {warnings:?}"
    );
}