rumdl 0.1.70

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
//! Tests for the --flavor CLI option
//!
//! Validates that the --flavor CLI argument correctly overrides
//! the config file flavor setting.

use std::fs;
use std::process::Command;
use tempfile::tempdir;

/// Helper to run rumdl check with given arguments
fn run_rumdl(dir: &std::path::Path, args: &[&str]) -> (bool, String, String) {
    let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
    let output = Command::new(rumdl_exe)
        .current_dir(dir)
        .args(args)
        .output()
        .expect("Failed to execute rumdl");
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    (output.status.success(), stdout, stderr)
}

#[test]
fn test_flavor_cli_option_recognized() {
    let temp_dir = tempdir().unwrap();
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, "# Test\n\nSome content.\n").unwrap();

    // Test that --flavor is recognized and doesn't error
    let (success, stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "--flavor", "mkdocs", "test.md"]);
    assert!(success, "Command should succeed. stderr: {stderr}, stdout: {stdout}");
}

#[test]
fn test_flavor_cli_all_variants() {
    let temp_dir = tempdir().unwrap();
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, "# Test\n\nSome content.\n").unwrap();

    // Test all valid flavor values (including aliases accepted by clap parser).
    for flavor in [
        "standard",
        "gfm",
        "github",
        "commonmark",
        "mkdocs",
        "mdx",
        "quarto",
        "qmd",
        "rmd",
        "rmarkdown",
        "obsidian",
        "kramdown",
        "jekyll",
    ] {
        let (success, stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "--flavor", flavor, "test.md"]);
        assert!(
            success,
            "Command should succeed for flavor '{flavor}'. stderr: {stderr}, stdout: {stdout}"
        );
    }
}

#[test]
fn test_flavor_cli_invalid_value() {
    let temp_dir = tempdir().unwrap();
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, "# Test\n\nSome content.\n").unwrap();

    // Test invalid flavor value
    let (success, _stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "--flavor", "invalid_flavor", "test.md"]);
    assert!(!success, "Command should fail for invalid flavor");
    assert!(
        stderr.contains("invalid_flavor") || stderr.contains("possible values"),
        "Error should mention invalid value. stderr: {stderr}"
    );
}

#[test]
fn test_flavor_cli_overrides_config() {
    let temp_dir = tempdir().unwrap();

    // Create config with standard flavor
    let config_content = r#"
[global]
flavor = "standard"
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    // Create a markdown file with MkDocs admonition
    let md_content = r#"# Test

!!! note "MkDocs Admonition"
    This should trigger MD022 in standard mode but not in mkdocs mode.
"#;
    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();

    // Run without --flavor override (uses config's standard)
    let (_success_std, stdout_std, _) = run_rumdl(temp_dir.path(), &["check", "test.md"]);

    // Run with --flavor mkdocs override
    let (_success_mkdocs, stdout_mkdocs, _stderr_mkdocs) =
        run_rumdl(temp_dir.path(), &["check", "--flavor", "mkdocs", "test.md"]);

    // The key test is that both commands complete without panic.
    // The fact that run_rumdl returns means the command executed.
    // We just log the output for debugging.
    println!("Standard mode: {stdout_std}");
    println!("MkDocs mode: {stdout_mkdocs}");
}

#[test]
fn test_flavor_cli_with_output_format() {
    let temp_dir = tempdir().unwrap();
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, "# Test\n\nSome content.\n").unwrap();

    // Test combining --flavor with --output-format
    let (success, stdout, stderr) = run_rumdl(
        temp_dir.path(),
        &["check", "--flavor", "mkdocs", "--output-format", "json", "test.md"],
    );
    assert!(success, "Command should succeed with both options. stderr: {stderr}");
    // JSON output should be valid (either empty array or object)
    assert!(
        stdout.trim().is_empty() || stdout.starts_with('[') || stdout.starts_with('{'),
        "Output should be valid JSON. stdout: {stdout}"
    );
}

#[test]
fn test_flavor_cli_with_enable_disable() {
    let temp_dir = tempdir().unwrap();
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, "# Test\n\nSome content.\n").unwrap();

    // Test combining --flavor with --enable
    let (success, _stdout, stderr) = run_rumdl(
        temp_dir.path(),
        &["check", "--flavor", "mkdocs", "--enable", "MD001,MD003", "test.md"],
    );
    assert!(
        success,
        "Command should succeed with --flavor and --enable. stderr: {stderr}"
    );

    // Test combining --flavor with --disable
    let (success, _stdout, stderr) = run_rumdl(
        temp_dir.path(),
        &["check", "--flavor", "quarto", "--disable", "MD013", "test.md"],
    );
    assert!(
        success,
        "Command should succeed with --flavor and --disable. stderr: {stderr}"
    );
}

#[test]
fn test_flavor_mdx_jsx_support() {
    let temp_dir = tempdir().unwrap();

    // Create an MDX file with JSX content
    let mdx_content = r#"# MDX Test

<CustomComponent prop="value">
  Some content inside a custom component.
</CustomComponent>

Regular paragraph.
"#;
    fs::write(temp_dir.path().join("test.mdx"), mdx_content).unwrap();

    // Run with MDX flavor - command completing without panic is the test
    let (_success, _stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", "--flavor", "mdx", "test.mdx"]);
}

#[test]
fn test_flavor_quarto_support() {
    let temp_dir = tempdir().unwrap();

    // Create a Quarto file with callouts
    let qmd_content = r#"---
title: "Quarto Test"
---

# Quarto Document

:::{.callout-note}
This is a Quarto callout note.
:::

Regular paragraph.
"#;
    fs::write(temp_dir.path().join("test.qmd"), qmd_content).unwrap();

    // Run with Quarto flavor - command completing without panic is the test
    let (_success, _stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", "--flavor", "quarto", "test.qmd"]);
}

/// End-to-end test: Obsidian flavor skips tag syntax in MD018
///
/// Verifies that --flavor obsidian actually affects MD018 behavior,
/// skipping Obsidian tag patterns (#tagname) while still flagging
/// multi-hash patterns (##tag) and digit-starting patterns (#123).
#[test]
fn test_obsidian_flavor_md018_tags() {
    let temp_dir = tempdir().unwrap();

    // Create a markdown file with Obsidian tags and malformed headings
    let md_content = r#"# Real Heading

#todo this is an Obsidian tag

#project/active nested tag

##Introduction

#123
"#;
    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();

    // Run with standard flavor - should flag ALL single-hash patterns
    let (success_std, stdout_std, _stderr_std) =
        run_rumdl(temp_dir.path(), &["check", "--flavor", "standard", "test.md"]);
    assert!(!success_std, "Standard flavor should find issues");

    // Count MD018 warnings in standard mode
    let std_md018_count = stdout_std.matches("MD018").count();
    assert!(
        std_md018_count >= 4,
        "Standard flavor should flag at least 4 MD018 issues (#todo, #project/active, ##Introduction, #123). Found {std_md018_count}. stdout: {stdout_std}"
    );

    // Run with obsidian flavor - should skip tags, flag only ##Introduction and #123
    let (success_obs, stdout_obs, _stderr_obs) =
        run_rumdl(temp_dir.path(), &["check", "--flavor", "obsidian", "test.md"]);
    assert!(!success_obs, "Obsidian flavor should still find some issues");

    // Count MD018 warnings in obsidian mode
    let obs_md018_count = stdout_obs.matches("MD018").count();
    assert_eq!(
        obs_md018_count, 2,
        "Obsidian flavor should flag exactly 2 MD018 issues (##Introduction, #123). Found {obs_md018_count}. stdout: {stdout_obs}"
    );

    // Verify specific patterns are NOT flagged
    // Note: Output format is "file:LINE:COLUMN:", so we check for "test.md:LINE:" pattern
    assert!(
        !stdout_obs.contains("test.md:3:"),
        "#todo (line 3) should NOT be flagged in Obsidian flavor. stdout: {stdout_obs}"
    );
    assert!(
        !stdout_obs.contains("test.md:5:"),
        "#project/active (line 5) should NOT be flagged in Obsidian flavor. stdout: {stdout_obs}"
    );
}

/// End-to-end test: Obsidian flavor works with config file
#[test]
fn test_obsidian_flavor_config_file() {
    let temp_dir = tempdir().unwrap();

    // Create config with obsidian flavor
    let config_content = r#"
[global]
flavor = "obsidian"
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    // Create markdown with Obsidian tag
    let md_content = "#todo this is a tag\n";
    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();

    // Run without --flavor flag (should use config's obsidian)
    let (success, stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "test.md"]);

    // Should pass (no MD018 warning) because #todo is an Obsidian tag
    assert!(
        success,
        "Obsidian flavor from config should skip #todo tag. stdout: {stdout}, stderr: {stderr}"
    );
    assert!(
        !stdout.contains("MD018"),
        "#todo should NOT be flagged when flavor=obsidian in config. stdout: {stdout}"
    );
}

/// End-to-end test: Obsidian fix mode preserves tags
#[test]
fn test_obsidian_flavor_fix_preserves_tags() {
    let temp_dir = tempdir().unwrap();

    // Create markdown with tags and malformed headings
    let md_content = "#todo tag\n\n##Introduction\n";
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, md_content).unwrap();

    // Run fix with obsidian flavor
    let (success, _stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "--fix", "--flavor", "obsidian", "test.md"]);
    assert!(success, "Fix command should succeed. stderr: {stderr}");

    // Read the fixed content
    let fixed_content = fs::read_to_string(&md_path).expect("Should read fixed file");

    // #todo should be preserved (not changed to "# todo")
    assert!(
        fixed_content.contains("#todo tag"),
        "#todo should be preserved in Obsidian flavor. Fixed content: {fixed_content}"
    );

    // ##Introduction should be fixed to "## Introduction"
    assert!(
        fixed_content.contains("## Introduction"),
        "##Introduction should be fixed to '## Introduction'. Fixed content: {fixed_content}"
    );
}

/// End-to-end test: MD018 magiclink config option
///
/// Verifies that [MD018] magiclink = true skips MagicLink-style issue refs (#123)
/// while still flagging non-numeric patterns (#Summary).
#[test]
fn test_md018_magiclink_config() {
    let temp_dir = tempdir().unwrap();

    // Create config with magiclink enabled
    let config_content = r#"
[MD018]
magiclink = true
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    // Create markdown with MagicLink patterns and malformed headings
    let md_content = r#"# Real Heading

#10 discusses the issue

#37 is another reference

#Summary
"#;
    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();

    // Run with magiclink config - should skip #10 and #37, flag #Summary
    let (success, stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", "test.md"]);
    assert!(!success, "Should find issues (at least #Summary)");

    // Count MD018 warnings
    let md018_count = stdout.matches("MD018").count();
    assert_eq!(
        md018_count, 1,
        "With magiclink=true, should flag exactly 1 MD018 issue (#Summary). Found {md018_count}. stdout: {stdout}"
    );

    // Verify #10 and #37 are NOT flagged (lines 3 and 5)
    assert!(
        !stdout.contains("test.md:3:"),
        "#10 (line 3) should NOT be flagged with magiclink=true. stdout: {stdout}"
    );
    assert!(
        !stdout.contains("test.md:5:"),
        "#37 (line 5) should NOT be flagged with magiclink=true. stdout: {stdout}"
    );
}

/// End-to-end test: MD018 without magiclink config flags all patterns
#[test]
fn test_md018_without_magiclink_config() {
    let temp_dir = tempdir().unwrap();

    // No config file - default behavior

    // Create markdown with MagicLink patterns
    let md_content = r#"# Real Heading

#10 discusses the issue

#Summary
"#;
    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();

    // Run without magiclink config - should flag ALL patterns
    let (success, stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", "test.md"]);
    assert!(!success, "Should find issues");

    // Count MD018 warnings - should be 2 (#10 and #Summary)
    let md018_count = stdout.matches("MD018").count();
    assert_eq!(
        md018_count, 2,
        "Without magiclink config, should flag 2 MD018 issues (#10, #Summary). Found {md018_count}. stdout: {stdout}"
    );
}

/// End-to-end test: MD018 magiclink fix preserves issue refs
#[test]
fn test_md018_magiclink_fix_preserves_refs() {
    let temp_dir = tempdir().unwrap();

    // Create config with magiclink enabled
    let config_content = r#"
[MD018]
magiclink = true
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    // Create markdown with MagicLink ref and malformed heading
    let md_content = "#10 is an issue\n\n#Summary\n";
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, md_content).unwrap();

    // Run fix with magiclink config
    let (success, _stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "--fix", "test.md"]);
    assert!(success, "Fix command should succeed. stderr: {stderr}");

    // Read the fixed content
    let fixed_content = fs::read_to_string(&md_path).expect("Should read fixed file");

    // #10 should be preserved (not changed to "# 10")
    assert!(
        fixed_content.contains("#10 is an issue"),
        "#10 should be preserved with magiclink=true. Fixed content: {fixed_content}"
    );

    // #Summary should be fixed to "# Summary"
    assert!(
        fixed_content.contains("# Summary"),
        "#Summary should be fixed to '# Summary'. Fixed content: {fixed_content}"
    );
}

/// End-to-end test: MD018 tags config enables tag recognition without Obsidian flavor
#[test]
fn test_md018_tags_config_standard_flavor() {
    let temp_dir = tempdir().unwrap();

    // Create config with tags enabled (no Obsidian flavor)
    let config_content = r#"
[MD018]
tags = true
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    // Create markdown with tag patterns and malformed headings
    let md_content = r#"# Real Heading

#todo this is a tag

#project/active nested tag

##Introduction

#123
"#;
    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();

    // Run with tags config - should skip tags, flag ##Introduction and #123
    let (success, stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", "test.md"]);
    assert!(!success, "Should find issues (##Introduction, #123)");

    let md018_count = stdout.matches("MD018").count();
    assert_eq!(
        md018_count, 2,
        "With tags=true, should flag exactly 2 MD018 issues (##Introduction, #123). Found {md018_count}. stdout: {stdout}"
    );

    // Tags should NOT be flagged
    assert!(
        !stdout.contains("test.md:3:"),
        "#todo (line 3) should NOT be flagged with tags=true. stdout: {stdout}"
    );
    assert!(
        !stdout.contains("test.md:5:"),
        "#project/active (line 5) should NOT be flagged with tags=true. stdout: {stdout}"
    );
}

/// End-to-end test: MD018 tags=false overrides Obsidian flavor default
#[test]
fn test_md018_tags_config_override_obsidian() {
    let temp_dir = tempdir().unwrap();

    // Create config with Obsidian flavor but tags explicitly disabled
    let config_content = r#"
[global]
flavor = "obsidian"

[MD018]
tags = false
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    let md_content = r#"# Real Heading

#todo

#project/active
"#;
    fs::write(temp_dir.path().join("test.md"), md_content).unwrap();

    // With tags=false, should flag tag patterns even in Obsidian flavor
    let (success, stdout, _stderr) = run_rumdl(temp_dir.path(), &["check", "test.md"]);
    assert!(!success, "Should find issues with tags=false");

    let md018_count = stdout.matches("MD018").count();
    assert_eq!(
        md018_count, 2,
        "With tags=false in Obsidian flavor, should flag tag patterns. Found {md018_count}. stdout: {stdout}"
    );
}

/// End-to-end test: MD018 tags config fix preserves tags
#[test]
fn test_md018_tags_config_fix_preserves_tags() {
    let temp_dir = tempdir().unwrap();

    let config_content = r#"
[MD018]
tags = true
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    let md_content = "#todo\n\n#Summary\n";
    let md_path = temp_dir.path().join("test.md");
    fs::write(&md_path, md_content).unwrap();

    let (success, _stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "--fix", "test.md"]);
    assert!(success, "Fix command should succeed. stderr: {stderr}");

    let fixed_content = fs::read_to_string(&md_path).expect("Should read fixed file");

    // Both #todo and #Summary match the tag pattern (# + non-digit non-space),
    // so neither should be modified
    assert!(
        fixed_content.contains("#todo"),
        "#todo should be preserved with tags=true. Fixed content: {fixed_content}"
    );
    assert!(
        fixed_content.contains("#Summary"),
        "#Summary should be preserved with tags=true (matches tag pattern). Fixed content: {fixed_content}"
    );
}

/// Regression test: Fix coordination must respect per-file-flavor configuration.
///
/// Bug: FixCoordinator used config.markdown_flavor() (global) instead of
/// config.get_flavor_for_file() (per-file), causing MkDocs content inside
/// admonitions to not be fixed because the fix phase didn't recognize
/// the MkDocs syntax.
#[test]
fn test_per_file_flavor_fix_coordination() {
    let temp_dir = tempdir().unwrap();

    // Create config with per-file-flavor for MkDocs (NOT global flavor)
    // The global flavor is NOT set to mkdocs, so if per-file-flavor is ignored,
    // the fix won't recognize MkDocs admonition syntax
    let config_content = r#"
[global]
enable = ["MD013"]
line-length = 80

[per-file-flavor]
"docs/**/*.md" = "mkdocs"

[MD013]
line-length = 80
reflow = true
"#;
    fs::write(temp_dir.path().join(".rumdl.toml"), config_content).unwrap();

    // Create docs directory and markdown file with MkDocs admonition
    // The content inside the admonition has a long line that should be reflowed
    let docs_dir = temp_dir.path().join("docs");
    fs::create_dir(&docs_dir).unwrap();

    let md_content = r#"# Test

!!! note "Important Note"
    This is a very long line inside an MkDocs admonition that exceeds the 80 character line length limit and should be reflowed by the fix command.
"#;
    let md_path = docs_dir.join("test.md");
    fs::write(&md_path, md_content).unwrap();

    // Run fix mode
    let (success, _stdout, stderr) = run_rumdl(temp_dir.path(), &["check", "--fix", "docs/test.md"]);

    // The command should succeed (exit 0)
    assert!(success, "Fix command should succeed. stderr: {stderr}");

    // The key test is that the content was actually modified
    // (proving that fix coordination used the per-file-flavor and recognized MkDocs syntax)
    let fixed_content = fs::read_to_string(&md_path).expect("Should read fixed file");

    // Verify the content was modified (the long line should have been reflowed)
    // The original content had one line starting with "    This is a very long line"
    // After reflow, that line should be different (wrapped into multiple lines or reformatted)
    let original_long_line = "    This is a very long line inside an MkDocs admonition that exceeds the 80 character line length limit and should be reflowed by the fix command.";

    assert!(
        !fixed_content.contains(original_long_line),
        "Long line should have been modified by fix.\n\
         This proves per-file-flavor was respected in fix coordination.\n\
         If the line is unchanged, fix coordination likely used global flavor (standard) \n\
         instead of per-file flavor (mkdocs), failing to recognize admonition content.\n\
         Fixed content:\n{fixed_content}\n\
         stderr: {stderr}"
    );
}