rumdl 0.2.21

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
// Comprehensive test suite for MD051 rule to address gaps identified in issue 39
// This test suite covers all the edge cases and missing scenarios that would have
// caught the bugs before they were released.

use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::MD051LinkFragments;

/// Helper function to assert that fragments are generated correctly
fn assert_fragments(test_cases: &[(&str, &str)]) {
    let rule = MD051LinkFragments::new();

    for (heading, expected_fragment) in test_cases {
        let content = format!("# {heading}\n\n[Link](#{expected_fragment})");
        let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            0,
            "Fragment generation failed for heading '{}': expected '{}' but link was flagged as broken. Warnings: {:?}",
            heading,
            expected_fragment,
            result.iter().map(|w| &w.message).collect::<Vec<_>>()
        );
    }
}

// Removed unused assert_fragment_variations function

#[test]
fn test_punctuation_removal_order() {
    // Test official GitHub/kramdown behavior for punctuation handling
    // Fixed: All official tools generate "--" for ampersands, not single "-"
    assert_fragments(&[
        ("A & B", "a--b"),                 // ampersand becomes "--", space becomes "-"
        ("A --> B", "a----b"),             // "-->" becomes "----", spaces absorbed
        ("A: B", "a-b"),                   // colon removed, space becomes hyphen
        ("A (B) C", "a-b-c"),              // parens removed, spaces become hyphens
        ("A!!! B", "a-b"),                 // multiple punctuation removed
        ("Pre -> Post", "pre---post"),     // "->": becomes "---"
        ("Before: After", "before-after"), // simple colon
    ]);
}

#[test]
fn test_consecutive_punctuation_handling() {
    // These are the specific failing cases from issue 39
    // Current algorithm generates wrong fragments with multiple hyphens
    // Fixed: GitHub actually generates "cbrown----sbrown---unsafe-paths"
    // Verified against GitHub gist behavior
    assert_fragments(&[("cbrown --> sbrown: --unsafe-paths", "cbrown----sbrown---unsafe-paths")]);

    // Fixed: GitHub actually generates "cbrown---sbrown"
    // Verified against official behavior
    assert_fragments(&[("cbrown -> sbrown", "cbrown---sbrown")]);

    // Additional complex punctuation patterns - fixed with actual GitHub behavior
    assert_fragments(&[
        ("API!!! Methods??? & Properties", "api-methods--properties"), // & becomes --
        ("Step 1: (Optional) Setup", "step-1-optional-setup"),
        ("Testing & Coverage & More", "testing--coverage--more"), // & becomes --
        ("One -> Two -> Three", "one---two---three"),             // -> becomes ---
        ("A: B: C", "a-b-c"),
    ]);
}

#[test]
fn test_ampersand_handling_variations() {
    // Test official GitHub ampersand handling behavior
    // Ampersand with spaces becomes "--", without spaces it's removed
    assert_fragments(&[
        ("Testing & Coverage", "testing--coverage"),   // With spaces: & becomes --
        ("A&B", "ab"),                                 // No spaces: & is removed
        ("A & B & C", "a--b--c"),                      // Multiple ampersands with spaces
        ("Testing&Development", "testingdevelopment"), // Adjacent ampersand is removed
        ("API & Documentation", "api--documentation"), // Common pattern with spaces
    ]);

    // Remove the variations test since official behavior is consistent
    // All official tools (GitHub, kramdown GFM, pure kramdown) generate "testing--coverage"
}

#[test]
fn test_complex_punctuation_clusters() {
    // Test combinations of different punctuation types - fixed with GitHub behavior
    assert_fragments(&[
        ("Title: (Part 1) - Overview", "title-part-1---overview"), // Space + hyphen + space → ---
        ("FAQ??? What's New!!!", "faq-whats-new"),
        ("Step 1: Setup (Required)", "step-1-setup-required"),
        (
            "API Reference: Methods & Properties",
            "api-reference-methods--properties", // & becomes --
        ),
        ("Version 2.0: New Features & Fixes", "version-20-new-features--fixes"), // & becomes --
        ("Install: (macOS) & (Windows)", "install-macos--windows"),              // & becomes --
    ]);
}

#[test]
fn test_special_character_edge_cases() {
    // Test punctuation characters that might be handled inconsistently - fixed with GitHub behavior
    assert_fragments(&[
        ("Quote \"Test\" Unquote", "quote-test-unquote"),
        ("Em—Dash & En–Dash", "emdash--endash"), // & becomes --, em/en dashes are removed entirely
        ("Symbols: @#$%^&*()", "symbols-"),      // All symbols removed, just one hyphen from colon space
        ("Math: x + y = z", "math-x--y--z"),     // spaces become hyphens, + becomes hyphen
        ("Code: foo(bar)", "code-foobar"),
        ("File.ext: Details", "fileext-details"),
    ]);
}

#[test]
fn test_unicode_punctuation_edge_cases() {
    // Test Unicode punctuation that might be missed by ASCII-only filters
    assert_fragments(&[
        ("Smart \"Quotes\" Test", "smart-quotes-test"),
        ("List • Item • Two", "list--item--two"),       // Bullet becomes --
        ("Range … Ellipsis", "range--ellipsis"),        // Ellipsis becomes --
        ("Math ÷ Division", "math--division"),          // Division sign becomes --
        ("Degree 90° Angle", "degree-90-angle"),        // Degree symbol removed
        ("Currency $100€ Price", "currency-100-price"), // Currency symbols removed
    ]);
}

#[test]
fn test_mixed_script_edge_cases() {
    // Test mixed scripts with punctuation - fixed with actual GitHub behavior
    assert_fragments(&[
        ("English & 中文", "english--中文"),       // & becomes --
        ("Café & Restaurant", "café--restaurant"), // & becomes --
        ("Naïve & Smart", "naïve--smart"),         // & becomes --
        ("Русский & English", "русский--english"), // & becomes --
        ("العربية & English", "العربية--english"), // & becomes --
    ]);
}

#[test]
fn test_whitespace_punctuation_interaction() {
    // Test edge cases where punctuation is adjacent to various whitespace - fixed with GitHub behavior
    assert_fragments(&[
        ("A  &  B", "a--b"),                        // Multiple spaces around ampersand: & becomes --
        ("Trailing & ", "trailing-"),               // Trailing ampersand with space: becomes single -
        (" & Leading", "--leading"),                // Leading space before punctuation: space+& becomes --
        ("Multiple   Spaces", "multiple---spaces"), // Multiple spaces preserved as multiple hyphens
    ]);

    // These test cases with tabs/newlines need special handling since they create invalid headings
    // Tabs and newlines can't appear in markdown headings literally
}

#[test]
fn test_edge_case_heading_structures() {
    // Test headings that might confuse the parser
    assert_fragments(&[
        ("Hash in Heading", "hash-in-heading"),
        ("Heading with [brackets]", "heading-with-brackets"),
        ("*Emphasis* in Heading", "emphasis-in-heading"),
        ("`Code` in Heading", "code-in-heading"),
        ("**Bold** & *Italic*", "bold--italic"),
        ("Link [text](url) stripped", "link-text-stripped"),
    ]);
}

#[test]
fn test_kramdown_vs_github_differences() {
    use rumdl_lib::utils::anchor_styles::AnchorStyle;

    // Test cases where Kramdown and GitHub modes should differ
    let github_rule = MD051LinkFragments::with_anchor_style(AnchorStyle::GitHub);
    let kramdown_rule = MD051LinkFragments::with_anchor_style(AnchorStyle::Kramdown);
    let kramdown_gfm_rule = MD051LinkFragments::with_anchor_style(AnchorStyle::KramdownGfm);

    // Test cases where different anchor styles should behave differently
    // Based on verified behavior from official tools
    let test_cases = vec![
        ("test_method", "test_method", "test_method", "testmethod"), // GitHub/GFM preserve _, pure kramdown removes _
        ("Café Menu", "café-menu", "café-menu", "caf-menu"),         // Pure kramdown removes accents, others preserve
        ("über_cool", "über_cool", "über_cool", "bercool"), // Pure kramdown removes accents & _, others preserve _
        ("naïve_approach", "naïve_approach", "naïve_approach", "naveapproach"), // Pure kramdown removes accents & _
    ];

    for (heading, github_expected, kramdown_gfm_expected, kramdown_expected) in test_cases {
        // Test GitHub mode (preserves underscores)
        let content = format!("# {heading}\n\n[Link](#{github_expected})");
        let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
        let result = github_rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            0,
            "GitHub mode failed for '{heading}' -> '{github_expected}'"
        );

        // Test Kramdown GFM mode (preserves underscores)
        let content = format!("# {heading}\n\n[Link](#{kramdown_gfm_expected})");
        let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
        let result = kramdown_gfm_rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            0,
            "Kramdown GFM mode failed for '{heading}' -> '{kramdown_gfm_expected}'"
        );

        // Test Pure Kramdown mode (removes underscores)
        let content = format!("# {heading}\n\n[Link](#{kramdown_expected})");
        let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
        let result = kramdown_rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            0,
            "Pure Kramdown mode failed for '{heading}' -> '{kramdown_expected}'"
        );
    }
}

#[test]
fn test_liquid_template_complex_patterns() {
    // Test advanced Liquid patterns that might confuse the cross-file detection
    let rule = MD051LinkFragments::new();

    let content = r#"# Real Heading

## Another Section

These Liquid patterns should be ignored:
[Complex post]({% post_url 2023-03-25-post param="value" %}#section)
[Variable include]({% include file.html var=site.data %}#anchor)
[Nested tags]({% assign x = "val" %}{% link {{ x }}.md %}#frag)
[Variable path]({{ site.url }}/{{ page.slug }}#section)
[Complex variable]({{ site.posts | where: "slug", "test" | first | url }}#anchor)

Valid internal links:
[Should work](#real-heading)
[Should also work](#another-section)
[Should fail](#missing-section)
"#;

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

    // Should only flag the missing internal link
    assert_eq!(result.len(), 1, "Should only warn about missing-section");
    assert!(result[0].message.contains("missing-section"));
}

#[test]
fn test_cross_file_detection_edge_cases() {
    // Test edge cases in cross-file link detection that might cause false positives
    let rule = MD051LinkFragments::new();

    let content = r#"# Main Heading

Cross-file patterns (should be ignored):
[Complex path](./docs/api/methods.md#get-user)
[Query params](file.md?version=1.0&format=json#section)
[Encoded chars](file%20name.md#section%20name)
[Multiple dots](config.local.dev.yaml#database-config)
[Archive file](backup.tar.gz#file-list)
[Hidden file](.eslintrc.js#rules)
[Network path](//server.com/file.md#section)
[Absolute path](/usr/local/docs/manual.md#install)

Ambiguous patterns (might be fragment-only):
[No extension](somefile#section)
[Dot only](file.#section)
[Hidden no ext](.hidden#section)

Valid internal:
[Works](#main-heading)
[Fails](#missing)
"#;

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

    // Should flag ambiguous patterns + missing internal = 2 warnings
    // - somefile#section: treated as cross-file (tries somefile.md), NOT flagged
    // - file.#section: treated as ambiguous, flagged for #section
    // - .hidden#section: treated as cross-file (hidden file), NOT flagged
    // - #missing: missing internal link
    assert_eq!(result.len(), 2, "Expected 2 warnings (1 ambiguous + 1 missing)");

    // Verify one is about missing
    assert!(result.iter().any(|w| w.message.contains("missing")));
}

#[test]
fn test_performance_with_complex_patterns() {
    // Ensure complex patterns don't cause performance regression
    let mut content = String::from("# Performance Test\n\n");

    // Add many complex headings
    let complex_headings = [
        "Complex: (Pattern) -> Result & More!!!",
        "API Reference: Methods & Properties",
        "Step 1: Setup (Required) & Configuration",
        "FAQ??? What's New & Updated",
        "Testing & Coverage & Documentation",
        "Unicode: Café & 中文 & Русский",
        "Punctuation: @#$%^&*() Symbols",
        "Arrows: -> <- <-> <=> --> <--",
        "Multiple!!! Exclamations??? & Questions",
        "Mixed: A -> B: C & D (E) F",
    ];

    for (i, heading) in complex_headings.iter().enumerate() {
        content.push_str(&format!("## {heading}\n\n"));
        content.push_str("Some content here.\n\n");

        // Add links to some headings
        if i % 2 == 0 {
            let simple_fragment = heading
                .to_lowercase()
                .chars()
                .filter(|c| c.is_alphanumeric() || *c == ' ')
                .collect::<String>()
                .replace(' ', "-");
            content.push_str(&format!("[Link](#{simple_fragment})\n\n"));
        }
    }

    let start = std::time::Instant::now();
    let rule = MD051LinkFragments::new();
    let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let _result = rule.check(&ctx).unwrap();
    let duration = start.elapsed();

    // Performance should remain reasonable even with complex patterns
    // Note: Increased threshold to 100ms to account for system load variability
    assert!(
        duration.as_millis() < 100,
        "Performance regression: took {}ms (threshold: 100ms)",
        duration.as_millis()
    );
}

#[test]
fn test_regression_prevention_issue_39() {
    // Specific regression test for the exact patterns from issue 39
    // These MUST work to prevent the bug from reoccurring
    let rule = MD051LinkFragments::new();

    let issue_39_cases = vec![
        // Cases with corrected GitHub behavior
        ("Testing & Coverage", "testing--coverage"), // & becomes --
        (
            "API Reference: Methods & Properties",
            "api-reference-methods--properties", // & becomes --
        ),
        // These are the patterns that actually work with GitHub
    ];

    for (heading, expected_fragment) in issue_39_cases {
        let content = format!("# {heading}\n\n[Link](#{expected_fragment})");
        let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        if result.is_empty() {
            println!("✓ Issue 39 case PASSED: '{heading}' -> '{expected_fragment}'");
        } else {
            println!("⚠ Issue 39 case NEEDS FIX: '{heading}' -> '{expected_fragment}'");
            println!(
                "  Current behavior produces warnings: {:?}",
                result.iter().map(|w| &w.message).collect::<Vec<_>>()
            );
        }
    }

    // Ensure the corrected cases work with actual GitHub behavior
    assert_fragments(&[
        ("Testing & Coverage", "testing--coverage"), // Corrected: & becomes --
        (
            "API Reference: Methods & Properties",
            "api-reference-methods--properties", // Corrected: & becomes --
        ),
    ]);
}

#[test]
fn test_boundary_conditions() {
    // Test boundary conditions that might cause edge case failures
    assert_fragments(&[
        ("", ""),       // Empty heading (should generate empty fragment)
        ("   ", ""),    // Whitespace only
        ("!!!", ""),    // Punctuation only
        ("123", "123"), // Numbers only
        ("_", "_"),     // Single underscore
        ("-", ""),      // Single hyphen (should be trimmed)
        ("a", "a"),     // Single character
        ("A", "a"),     // Single uppercase character
    ]);
}

#[test]
fn test_markdown_formatting_in_headings() {
    // Test that markdown formatting is properly stripped from headings
    assert_fragments(&[
        ("**Bold** Text", "bold-text"),
        ("*Italic* Text", "italic-text"),
        ("`Code` Text", "code-text"),
        ("~~Strikethrough~~ Text", "strikethrough-text"),
        ("***Bold Italic*** Text", "bold-italic-text"),
        ("**_Mixed_** Formatting", "mixed-formatting"),
        ("[Link](url) Text", "link-text"),
        ("![Image](url) Text", "image-text"),
    ]);
}

#[test]
fn test_zero_width_and_control_characters() {
    // Test handling of zero-width and control characters
    assert_fragments(&[
        ("Zero\u{200B}Width", "zerowidth"),   // Zero-width space
        ("Soft\u{00AD}Hyphen", "softhyphen"), // Soft hyphen
        ("Word\u{2060}Joiner", "wordjoiner"), // Word joiner
    ]);

    // Note: Tab and newline characters can't appear literally in markdown headings
    // They would break the heading syntax
}

#[test]
fn test_duplicate_heading_numbering() {
    // Test that duplicate headings get numbered correctly
    let rule = MD051LinkFragments::new();

    let content = r#"# Duplicate

## Section

## Section

## Section

[Link 1](#duplicate)
[Link 2](#section)
[Link 3](#section-1)
[Link 4](#section-2)
[Invalid](#section-3)
"#;

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

    // Should only flag the invalid link (section-3 doesn't exist)
    assert_eq!(result.len(), 1, "Should only warn about section-3");
    assert!(result[0].message.contains("section-3"));
}

#[test]
fn test_custom_header_id_edge_cases() {
    // Test edge cases with custom header IDs
    let rule = MD051LinkFragments::new();

    let content = r#"# Normal Heading {#custom-id}

## Another {#with-hyphens-in-id}

### Third {:#colon-style}

#### Fourth {: #spaced-colon }

[Link 1](#custom-id)
[Link 2](#with-hyphens-in-id)
[Link 3](#colon-style)
[Link 4](#spaced-colon)
[Link 5](#normal-heading)
[Invalid](#missing-id)
"#;

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

    // Should only flag the invalid link
    assert_eq!(result.len(), 1, "Should only warn about missing-id");
    assert!(result[0].message.contains("missing-id"));
}