rumdl 0.1.88

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

#[test]
fn test_md009_valid() {
    let rule = MD009TrailingSpaces::default();
    let content = "Line without trailing spaces\nAnother line without trailing spaces\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_md009_invalid() {
    let rule = MD009TrailingSpaces::default();
    let content = "Line with trailing spaces  \nAnother line with trailing spaces   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Only the second line should be flagged (3 spaces)
    assert_eq!(result[0].line, 2);
    assert_eq!(result[0].message, "3 trailing spaces found");
}

#[test]
fn test_md009_empty_lines() {
    let rule = MD009TrailingSpaces::default();
    let content = "Line without trailing spaces\n  \nAnother line without trailing spaces\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].line, 2);
    assert_eq!(result[0].message, "Empty line has trailing spaces");
}

#[test]
fn test_md009_code_blocks() {
    let rule = MD009TrailingSpaces::default();
    let content = "Normal line\n```\nCode with spaces    \nMore code  \n```\nNormal line  \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 0); // Code block spaces are allowed
}

#[test]
fn test_md009_strict_mode() {
    let rule = MD009TrailingSpaces::new(2, true);
    // markdownlint parity: strict preserves the br_spaces exception on paragraph
    // lines (line 1) but still flags trailing spaces inside fenced code blocks.
    let content = "Line with two spaces  \nText before fence:\n```\nCode with spaces  \n```\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
    assert_eq!(lines_flagged, vec![4], "got: {result:?}");
}

#[test]
fn test_md009_line_breaks() {
    let rule = MD009TrailingSpaces::default();
    let content = "This is a line  \nWith hard breaks  \nBut this has three   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Only the line with 3 spaces should be flagged
    assert_eq!(result[0].line, 3);
}

#[test]
fn test_md009_custom_br_spaces() {
    let rule = MD009TrailingSpaces::new(3, false);
    let content = "Line with two spaces  \nLine with three   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Only the line with 2 spaces should be flagged
    assert_eq!(result[0].line, 1);
}

#[test]
fn test_md009_fix() {
    let rule = MD009TrailingSpaces::default();
    let content = "Line with spaces   \nAnother line  \nNo spaces\n  \n```\nCode   \n```\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    // Default br_spaces=2, so only lines with exactly 2 spaces are preserved
    // Line 1: 3 spaces -> removed
    // Line 2: 2 spaces -> preserved
    // Line 4: 2 spaces (empty line) -> removed
    assert_eq!(
        result,
        "Line with spaces\nAnother line  \nNo spaces\n\n```\nCode   \n```\n"
    );
}

#[test]
fn test_md009_fix_strict() {
    let rule = MD009TrailingSpaces::new(2, true);
    let content = "Line with spaces   \nAnother line  \nNo spaces\n  \n```\nCode   \n```\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    // Line 1: 3 trailing spaces -> stripped (not br_spaces match).
    // Line 2: 2 trailing spaces on a paragraph line -> preserved (markdownlint parity).
    // Line 4: blank-line whitespace -> stripped.
    // Line 6: trailing spaces inside code block -> stripped.
    assert_eq!(
        result,
        "Line with spaces\nAnother line  \nNo spaces\n\n```\nCode\n```\n"
    );
}

#[test]
fn test_md009_trailing_tabs() {
    let rule = MD009TrailingSpaces::default();
    let content = "Line with trailing tab\t\nLine with tabs and spaces\t  \nMixed at end  \t\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // Note: The rule only checks for trailing spaces, not tabs
    // So tabs are not detected, and "  \t" is detected as 2 trailing spaces
    assert_eq!(result.len(), 0); // The rule doesn't detect spaces followed by tabs as trailing spaces
}

#[test]
fn test_md009_multiple_trailing_spaces() {
    let rule = MD009TrailingSpaces::default();
    let content = "One space \nTwo spaces  \nThree spaces   \nFour spaces    \nFive spaces     \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 4); // Lines with 1, 3, 4, and 5 spaces should be flagged (2 spaces allowed for line breaks)
    assert_eq!(result[0].line, 1);
    assert_eq!(result[0].message, "Trailing space found");
    assert_eq!(result[1].line, 3);
    assert_eq!(result[1].message, "3 trailing spaces found");
    assert_eq!(result[2].line, 4);
    assert_eq!(result[2].message, "4 trailing spaces found");
    assert_eq!(result[3].line, 5);
    assert_eq!(result[3].message, "5 trailing spaces found");
}

#[test]
fn test_md009_lists_with_trailing_spaces() {
    let rule = MD009TrailingSpaces::default();
    let content = "- List item without spaces\n- List item with spaces  \n  - Nested with spaces   \n  - Nested without\n* Another list  \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Only lines with more than 2 spaces
    assert_eq!(result[0].line, 3);
    assert_eq!(result[0].message, "3 trailing spaces found");
}

#[test]
fn test_md009_blockquote_empty_lines() {
    let rule = MD009TrailingSpaces::default();
    let content = "> Quote\n>  \n> More quote\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 0); // ">  " is not detected as an empty blockquote line needing fixing
}

#[test]
fn test_md009_blockquote_truly_empty() {
    let rule = MD009TrailingSpaces::default();
    let content = "> Quote\n>   \n> More quote\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // ">   " should be detected
    assert_eq!(result[0].line, 2);
    assert_eq!(result[0].message, "3 trailing spaces found");
}

#[test]
fn test_md009_fix_preserves_line_breaks() {
    let rule = MD009TrailingSpaces::new(2, false);
    let content = "Line with one space \nLine with two  \nLine with three   \nLine with four    \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    // Only lines with exactly 2 spaces (br_spaces) are preserved
    // Lines with 1, 3, or 4 spaces have them removed
    assert_eq!(
        result,
        "Line with one space\nLine with two  \nLine with three\nLine with four\n"
    );
}

#[test]
fn test_md009_fix_empty_lines() {
    let rule = MD009TrailingSpaces::default();
    let content = "Text\n   \nMore text\n     \nEnd\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    assert_eq!(result, "Text\n\nMore text\n\nEnd\n");
}

#[test]
fn test_md009_br_spaces_configuration() {
    let rule = MD009TrailingSpaces::new(4, false);
    let content = "Two spaces  \nThree spaces   \nFour spaces    \nFive spaces     \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 3); // All except line with 4 spaces should be flagged
    assert_eq!(result[0].line, 1);
    assert_eq!(result[1].line, 2);
    assert_eq!(result[2].line, 4);
}

#[test]
fn test_md009_last_line_handling() {
    let rule = MD009TrailingSpaces::default();
    // Test with final newline
    let content_with_newline = "Line one  \nLine two  \nLast line  \n";
    let ctx = LintContext::new(content_with_newline, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert!(result.is_empty()); // All lines have valid 2-space line breaks

    // Test without final newline
    let content_without_newline = "Line one  \nLine two  \nLast line  ";
    let ctx = LintContext::new(
        content_without_newline,
        rumdl_lib::config::MarkdownFlavor::Standard,
        None,
    );
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Last line should be flagged
    assert_eq!(result[0].line, 3);
}

#[test]
fn test_md009_fix_last_line() {
    let rule = MD009TrailingSpaces::default();
    // Test without final newline
    let content = "Line one  \nLine two  \nLast line  ";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    assert_eq!(result, "Line one  \nLine two  \nLast line");

    // Test with final newline
    let content = "Line one  \nLine two  \nLast line  \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    assert_eq!(result, "Line one  \nLine two  \nLast line  \n");
}

#[test]
fn test_md009_code_blocks_strict_mode() {
    let rule = MD009TrailingSpaces::new(2, true);
    let content = "```python\ndef hello():  \n    print('world')   \n```\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 2); // In strict mode, code block spaces should be flagged
}

#[test]
fn test_md009_fix_blockquote_empty_lines() {
    let rule = MD009TrailingSpaces::default();
    let content = "> Quote\n>   \n> More quote\n>\n> End\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    assert_eq!(result, "> Quote\n>\n> More quote\n>\n> End\n"); // Trailing spaces removed
}

#[test]
fn test_md009_mixed_content() {
    let rule = MD009TrailingSpaces::default();
    let content = "# Heading  \n\nParagraph with line break  \nAnother line   \n\n- List item  \n- Another item    \n\n```\ncode  \n```\n\n> Quote  \n>   \n> More  \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // Should flag: line 4 (3 spaces), line 7 (4 spaces), line 12 (empty blockquote)
    assert_eq!(result.len(), 3);
}

#[test]
fn test_md009_column_positions() {
    let rule = MD009TrailingSpaces::default();
    let content = "Short  \nA longer line with spaces   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].line, 2);
    assert_eq!(result[0].column, 26); // Position of first trailing space
    assert_eq!(result[0].end_column, 29); // Position after last trailing space (3 spaces)
}

#[test]
fn test_md009_only_spaces_line() {
    let rule = MD009TrailingSpaces::default();
    let content = "Text\n    \nMore text\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].line, 2);
    assert_eq!(result[0].message, "Empty line has trailing spaces");
}

#[test]
fn test_md009_heading_with_trailing_spaces() {
    let rule = MD009TrailingSpaces::default();
    let content = "# Heading  \n## Another heading   \n### Third  \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Only line 2 with 3 spaces
    assert_eq!(result[0].line, 2);
    assert_eq!(result[0].message, "3 trailing spaces found");
}

#[test]
fn test_md009_table_with_trailing_spaces() {
    let rule = MD009TrailingSpaces::default();
    let content = "| Column 1 | Column 2  |\n|----------|-----------|  \n| Data     | More data |   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Only line 3 with 3 spaces
    assert_eq!(result[0].line, 3);
}

#[test]
fn test_md009_fix_with_crlf() {
    let rule = MD009TrailingSpaces::default();
    let content = "Line one  \r\nLine two   \r\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    // Line 1: 2 spaces -> preserved (matches br_spaces=2)
    // Line 2: 3 spaces -> removed (doesn't match br_spaces=2)
    // Line endings are preserved from the original document
    assert_eq!(result, "Line one  \r\nLine two\r\n");
}

#[test]
fn test_md009_indented_code_non_strict() {
    let rule = MD009TrailingSpaces::new(2, false);
    let content = "Text\n\n    indented code  \n    more code   \n\nText\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    // In non-strict mode, indented code blocks should be ignored
    assert_eq!(result.len(), 0);
}

#[test]
fn test_md009_fix_complex_document() {
    let rule = MD009TrailingSpaces::default();
    let content =
        "# Title   \n\nParagraph  \n\n- List   \n  - Nested  \n\n```\ncode   \n```\n\n> Quote   \n>    \n\nEnd  ";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.fix(&ctx).unwrap();
    // Headings should have all trailing spaces removed
    // Lines with exactly 2 spaces are preserved
    // Lines with 3 spaces are removed
    assert_eq!(
        result,
        "# Title\n\nParagraph  \n\n- List\n  - Nested  \n\n```\ncode   \n```\n\n> Quote\n>\n\nEnd" // Empty blockquote line trailing spaces removed
    );
}

#[test]
fn test_md009_unicode_content() {
    let rule = MD009TrailingSpaces::default();
    let content = "Unicode text 你好  \nAnother line 世界   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].line, 2);
    assert_eq!(result[0].message, "3 trailing spaces found");
}

#[test]
fn test_md009_nested_blockquotes() {
    let rule = MD009TrailingSpaces::default();
    let content = "> Level 1  \n> > Level 2   \n> > > Level 3  \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
    let result = rule.check(&ctx).unwrap();
    assert_eq!(result.len(), 1); // Only line 2 with 3 spaces
    assert_eq!(result[0].line, 2);
}

// ============================================================================
// Issue #248: Multi-byte character fix range tests
// These tests verify that fix ranges are correctly calculated for lines
// containing multi-byte UTF-8 characters (like €, 你, 🎉)
// ============================================================================

#[test]
fn test_md009_euro_sign_fix_range() {
    // Issue #248: Euro sign (€) is 3 bytes in UTF-8
    // The fix range must use character positions, not byte positions
    let rule = MD009TrailingSpaces::new(2, true); // strict mode to catch all trailing spaces
    let content = "- 1€ expenses \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);
    assert_eq!(warnings[0].line, 1);

    // Verify the fix range produces correct output when applied
    let fix = warnings[0].fix.as_ref().expect("Should have fix");

    // "- 1€ expenses " = 14 characters, 16 bytes (€ = 3 bytes)
    // Trailing space is at character position 14 (byte position 16)
    // Fix range should be 15..16 (byte range for the trailing space)
    assert_eq!(fix.range.start, 15);
    assert_eq!(fix.range.end, 16);

    // Verify the actual fix works correctly
    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "- 1€ expenses\n");
}

#[test]
fn test_md009_multiple_euro_signs_fix_range() {
    let rule = MD009TrailingSpaces::new(2, true);
    let content = "€100 + €50 = €150   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);
    assert_eq!(warnings[0].line, 1);
    assert_eq!(warnings[0].message, "3 trailing spaces found");

    let fix = warnings[0].fix.as_ref().expect("Should have fix");
    // "€100 + €50 = €150" = 17 characters, 23 bytes (3 € signs × 3 bytes each = 9 extra bytes)
    // 3 trailing spaces at byte positions 23, 24, 25
    assert_eq!(fix.range.start, 23);
    assert_eq!(fix.range.end, 26);

    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "€100 + €50 = €150\n");
}

#[test]
fn test_md009_cjk_characters_fix_range() {
    let rule = MD009TrailingSpaces::new(2, true);
    // Chinese: 你好世界 = 4 characters, 12 bytes (3 bytes each)
    let content = "Hello 你好世界   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);

    let fix = warnings[0].fix.as_ref().expect("Should have fix");
    // "Hello 你好世界" = 10 characters, 18 bytes
    // 3 trailing spaces at byte positions 18, 19, 20
    assert_eq!(fix.range.start, 18);
    assert_eq!(fix.range.end, 21);

    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "Hello 你好世界\n");
}

#[test]
fn test_md009_emoji_fix_range() {
    let rule = MD009TrailingSpaces::new(2, true);
    // Emoji 🎉 is 4 bytes in UTF-8
    let content = "Party 🎉🎉🎉   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);

    let fix = warnings[0].fix.as_ref().expect("Should have fix");
    // "Party 🎉🎉🎉" = 9 characters, 18 bytes (3 emoji × 4 bytes = 12, + 6 ASCII)
    // 3 trailing spaces at byte positions 18, 19, 20
    assert_eq!(fix.range.start, 18);
    assert_eq!(fix.range.end, 21);

    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "Party 🎉🎉🎉\n");
}

#[test]
fn test_md009_mixed_multibyte_fix_range() {
    let rule = MD009TrailingSpaces::new(2, true);
    // Mix of ASCII, Euro, CJK, and emoji. Use 3 trailing spaces (not br_spaces)
    // so the paragraph line is still flagged regardless of strict semantics.
    let content = "Price: €100 你好 🎉   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);
    assert_eq!(warnings[0].message, "3 trailing spaces found");

    // Verify the fix works correctly
    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "Price: €100 你好 🎉\n");
}

#[test]
fn test_md009_issue_248_exact_repro() {
    // Exact reproduction of issue #248
    let rule = MD009TrailingSpaces::new(2, true);
    let content = "- foobar \n- 1€ expenses \n\n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    // Line 1: 1 trailing space -> flagged
    // Line 2: 1 trailing space after multi-byte char -> flagged
    assert_eq!(warnings.len(), 2);

    // Verify both lines have valid fix ranges
    for warning in &warnings {
        let fix = warning.fix.as_ref().expect("Should have fix");
        assert!(fix.range.start < fix.range.end, "Fix range should not be empty");
    }

    // Verify the fix works correctly
    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "- foobar\n- 1€ expenses\n\n");
}

#[test]
fn test_md009_warning_based_fix_multibyte() {
    // Test that warning-based fixes (used by LSP) work correctly with multi-byte chars
    use rumdl_lib::utils::fix_utils::apply_warning_fixes;

    let rule = MD009TrailingSpaces::new(2, true);
    let content = "- 1€ expenses \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);

    // Apply warning-based fixes (simulates LSP formatting)
    let fixed = apply_warning_fixes(content, &warnings).expect("Should apply fixes");
    assert_eq!(fixed, "- 1€ expenses\n");
}

#[test]
fn test_md009_multiple_lines_multibyte_fix() {
    // Test multiple lines with multi-byte characters. All lines use trailing-space
    // counts that don't match br_spaces=2, so they're flagged in strict mode
    // regardless of paragraph context.
    use rumdl_lib::utils::fix_utils::apply_warning_fixes;

    let rule = MD009TrailingSpaces::new(2, true);
    let content = "€ price   \n¥ yen   \n£ pound \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 3);

    // Apply warning-based fixes
    let fixed = apply_warning_fixes(content, &warnings).expect("Should apply fixes");
    assert_eq!(fixed, "€ price\n¥ yen\n£ pound\n");
}

#[test]
fn test_md009_column_positions_multibyte() {
    // Verify column positions are character-based, not byte-based
    let rule = MD009TrailingSpaces::new(2, true);
    let content = "€€€   \n"; // 3 euro signs (9 bytes) + 3 spaces
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);

    // Column should be character position 4 (after 3 euro signs), not byte position 10
    assert_eq!(warnings[0].column, 4);
    // End column should be character position 7 (after 3 trailing spaces)
    assert_eq!(warnings[0].end_column, 7);
}

#[test]
fn test_md009_korean_fix_range() {
    // Korean characters (3 bytes each in UTF-8)
    let rule = MD009TrailingSpaces::new(2, true);
    let content = "안녕하세요   \n"; // "Hello" in Korean + 3 trailing spaces
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);

    let fix = warnings[0].fix.as_ref().expect("Should have fix");
    // "안녕하세요" = 5 characters, 15 bytes
    // 3 trailing spaces at byte positions 15, 16, 17
    assert_eq!(fix.range.start, 15);
    assert_eq!(fix.range.end, 18);

    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "안녕하세요\n");
}

#[test]
fn test_md009_combining_characters_fix() {
    // Test with combining characters (e.g., é as e + combining acute)
    let rule = MD009TrailingSpaces::new(2, true);
    // Using precomposed é (U+00E9, 2 bytes) vs decomposed e + ́ (1 + 2 bytes)
    let content = "café   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 1);

    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "café\n");
}

#[test]
fn test_md009_list_with_multibyte_marker_content() {
    // Test list items where the content after marker contains multi-byte chars.
    // Both lines use 3 trailing spaces (not br_spaces) so they're flagged in
    // strict mode regardless of paragraph context.
    let rule = MD009TrailingSpaces::new(2, true);
    let content = "- 价格: €50   \n- 價格: ¥100   \n";
    let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);

    let warnings = rule.check(&ctx).unwrap();
    assert_eq!(warnings.len(), 2);

    // Verify fixes work
    let fixed = rule.fix(&ctx).unwrap();
    assert_eq!(fixed, "- 价格: €50\n- 價格: ¥100\n");
}