Skip to main content

rumdl_lib/utils/
fix_utils.rs

1//! Utilities for applying fixes consistently between CLI and LSP
2//!
3//! This module provides shared logic for applying markdown fixes to ensure
4//! that both CLI batch fixes and LSP individual fixes produce identical results.
5
6use crate::inline_config::InlineConfig;
7use crate::rule::{Fix, LintWarning};
8use crate::utils::ensure_consistent_line_endings;
9
10/// Filter warnings by inline config, removing those on disabled lines.
11///
12/// Replicates the same filtering logic used in the check/reporting path
13/// (`src/lib.rs`) so that fix mode respects inline disable comments.
14pub fn filter_warnings_by_inline_config(
15    warnings: Vec<LintWarning>,
16    inline_config: &InlineConfig,
17    rule_name: &str,
18) -> Vec<LintWarning> {
19    let base_rule_name = if let Some(dash_pos) = rule_name.find('-') {
20        // Handle sub-rules like "MD029-style" -> "MD029"
21        // But only if the prefix looks like a rule ID (starts with "MD")
22        let prefix = &rule_name[..dash_pos];
23        if prefix.starts_with("MD") { prefix } else { rule_name }
24    } else {
25        rule_name
26    };
27
28    warnings
29        .into_iter()
30        .filter(|w| {
31            let end = if w.end_line >= w.line { w.end_line } else { w.line };
32            !(w.line..=end).any(|line| inline_config.is_rule_disabled(base_rule_name, line))
33        })
34        .collect()
35}
36
37/// Apply a list of warning fixes to content, simulating how the LSP client would apply them
38/// This is used for testing consistency between CLI and LSP fix methods
39pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
40    let mut fixes: Vec<(usize, &Fix)> = warnings
41        .iter()
42        .enumerate()
43        .filter_map(|(i, w)| w.fix.as_ref().map(|fix| (i, fix)))
44        .collect();
45
46    // No-op fast path: if there are no actual fixes to apply, return the
47    // content unchanged. This avoids unnecessary line-ending normalization
48    // when all warnings were filtered out (e.g., by inline config) or had
49    // no fix attached.
50    if fixes.is_empty() {
51        return Ok(content.to_string());
52    }
53
54    // Deduplicate fixes that operate on the same range with the same replacement
55    // This prevents double-application when multiple warnings target the same issue
56    fixes.sort_by(|(_, fix_a), (_, fix_b)| {
57        let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
58        if range_cmp != std::cmp::Ordering::Equal {
59            return range_cmp;
60        }
61        fix_a.range.end.cmp(&fix_b.range.end)
62    });
63
64    let mut deduplicated = Vec::new();
65    let mut i = 0;
66    while i < fixes.len() {
67        let (idx, current_fix) = fixes[i];
68        deduplicated.push((idx, current_fix));
69
70        // Skip any subsequent fixes that have the same range and replacement
71        while i + 1 < fixes.len() {
72            let (_, next_fix) = fixes[i + 1];
73            if current_fix.range == next_fix.range && current_fix.replacement == next_fix.replacement {
74                i += 1; // Skip the duplicate
75            } else {
76                break;
77            }
78        }
79        i += 1;
80    }
81
82    let mut fixes = deduplicated;
83
84    // Sort fixes by range in reverse order (end to start) to avoid offset issues
85    // Use original index as secondary sort key to ensure stable sorting
86    fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
87        // Primary: sort by range start in reverse order (largest first)
88        let range_cmp = fix_b.range.start.cmp(&fix_a.range.start);
89        if range_cmp != std::cmp::Ordering::Equal {
90            return range_cmp;
91        }
92
93        // Secondary: sort by range end in reverse order
94        let end_cmp = fix_b.range.end.cmp(&fix_a.range.end);
95        if end_cmp != std::cmp::Ordering::Equal {
96            return end_cmp;
97        }
98
99        // Tertiary: maintain original order for identical ranges (stable sort)
100        idx_a.cmp(idx_b)
101    });
102
103    let mut result = content.to_string();
104
105    // Track the lowest byte offset touched by an already-applied fix.
106    // Since fixes are sorted in reverse order (highest start first),
107    // any subsequent fix whose range.end > min_applied_start would
108    // overlap with an already-applied fix and corrupt the result.
109    let mut min_applied_start = usize::MAX;
110
111    for (_, fix) in fixes {
112        // Validate range bounds
113        if fix.range.end > result.len() {
114            return Err(format!(
115                "Fix range end {} exceeds content length {}",
116                fix.range.end,
117                result.len()
118            ));
119        }
120
121        if fix.range.start > fix.range.end {
122            return Err(format!(
123                "Invalid fix range: start {} > end {}",
124                fix.range.start, fix.range.end
125            ));
126        }
127
128        // Skip fixes that overlap with an already-applied fix to prevent
129        // offset corruption (e.g., nested link/image constructs in MD039).
130        if fix.range.end > min_applied_start {
131            continue;
132        }
133
134        // Apply the fix by replacing the range with the replacement text
135        result.replace_range(fix.range.clone(), &fix.replacement);
136        min_applied_start = fix.range.start;
137    }
138
139    // Ensure line endings are consistent with the original document
140    Ok(ensure_consistent_line_endings(content, &result))
141}
142
143/// Convert a single warning fix to a text edit-style representation
144/// This helps validate that individual warning fixes are correctly structured
145pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
146    if let Some(fix) = &warning.fix {
147        // Validate the fix range against content
148        if fix.range.end > content.len() {
149            return Err(format!(
150                "Fix range end {} exceeds content length {}",
151                fix.range.end,
152                content.len()
153            ));
154        }
155
156        Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
157    } else {
158        Err("Warning has no fix".to_string())
159    }
160}
161
162/// Helper function to validate that a fix range makes sense in the context
163pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
164    if fix.range.start > content.len() {
165        return Err(format!(
166            "Fix range start {} exceeds content length {}",
167            fix.range.start,
168            content.len()
169        ));
170    }
171
172    if fix.range.end > content.len() {
173        return Err(format!(
174            "Fix range end {} exceeds content length {}",
175            fix.range.end,
176            content.len()
177        ));
178    }
179
180    if fix.range.start > fix.range.end {
181        return Err(format!(
182            "Invalid fix range: start {} > end {}",
183            fix.range.start, fix.range.end
184        ));
185    }
186
187    Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::rule::{Fix, LintWarning, Severity};
194
195    #[test]
196    fn test_apply_single_fix() {
197        let content = "1.  Multiple spaces";
198        let warning = LintWarning {
199            message: "Too many spaces".to_string(),
200            line: 1,
201            column: 3,
202            end_line: 1,
203            end_column: 5,
204            severity: Severity::Warning,
205            fix: Some(Fix {
206                range: 2..4,                  // "  " (two spaces)
207                replacement: " ".to_string(), // single space
208            }),
209            rule_name: Some("MD030".to_string()),
210        };
211
212        let result = apply_warning_fixes(content, &[warning]).unwrap();
213        assert_eq!(result, "1. Multiple spaces");
214    }
215
216    #[test]
217    fn test_apply_multiple_fixes() {
218        let content = "1.  First\n*   Second";
219        let warnings = vec![
220            LintWarning {
221                message: "Too many spaces".to_string(),
222                line: 1,
223                column: 3,
224                end_line: 1,
225                end_column: 5,
226                severity: Severity::Warning,
227                fix: Some(Fix {
228                    range: 2..4, // First line "  "
229                    replacement: " ".to_string(),
230                }),
231                rule_name: Some("MD030".to_string()),
232            },
233            LintWarning {
234                message: "Too many spaces".to_string(),
235                line: 2,
236                column: 2,
237                end_line: 2,
238                end_column: 5,
239                severity: Severity::Warning,
240                fix: Some(Fix {
241                    range: 11..14, // Second line "   " (after newline + "*")
242                    replacement: " ".to_string(),
243                }),
244                rule_name: Some("MD030".to_string()),
245            },
246        ];
247
248        let result = apply_warning_fixes(content, &warnings).unwrap();
249        assert_eq!(result, "1. First\n* Second");
250    }
251
252    #[test]
253    fn test_apply_non_overlapping_fixes() {
254        // "Test  multiple    spaces"
255        //  0123456789012345678901234
256        //      ^^       ^^^^
257        //      4-6      14-18
258        let content = "Test  multiple    spaces";
259        let warnings = vec![
260            LintWarning {
261                message: "Too many spaces".to_string(),
262                line: 1,
263                column: 5,
264                end_line: 1,
265                end_column: 7,
266                severity: Severity::Warning,
267                fix: Some(Fix {
268                    range: 4..6, // "  " after "Test"
269                    replacement: " ".to_string(),
270                }),
271                rule_name: Some("MD009".to_string()),
272            },
273            LintWarning {
274                message: "Too many spaces".to_string(),
275                line: 1,
276                column: 15,
277                end_line: 1,
278                end_column: 19,
279                severity: Severity::Warning,
280                fix: Some(Fix {
281                    range: 14..18, // "    " after "multiple"
282                    replacement: " ".to_string(),
283                }),
284                rule_name: Some("MD009".to_string()),
285            },
286        ];
287
288        let result = apply_warning_fixes(content, &warnings).unwrap();
289        assert_eq!(result, "Test multiple spaces");
290    }
291
292    #[test]
293    fn test_apply_duplicate_fixes() {
294        let content = "Test  content";
295        let warnings = vec![
296            LintWarning {
297                message: "Fix 1".to_string(),
298                line: 1,
299                column: 5,
300                end_line: 1,
301                end_column: 7,
302                severity: Severity::Warning,
303                fix: Some(Fix {
304                    range: 4..6,
305                    replacement: " ".to_string(),
306                }),
307                rule_name: Some("MD009".to_string()),
308            },
309            LintWarning {
310                message: "Fix 2 (duplicate)".to_string(),
311                line: 1,
312                column: 5,
313                end_line: 1,
314                end_column: 7,
315                severity: Severity::Warning,
316                fix: Some(Fix {
317                    range: 4..6,
318                    replacement: " ".to_string(),
319                }),
320                rule_name: Some("MD009".to_string()),
321            },
322        ];
323
324        // Duplicates should be deduplicated
325        let result = apply_warning_fixes(content, &warnings).unwrap();
326        assert_eq!(result, "Test content");
327    }
328
329    #[test]
330    fn test_apply_fixes_with_windows_line_endings() {
331        let content = "1.  First\r\n*   Second\r\n";
332        let warnings = vec![
333            LintWarning {
334                message: "Too many spaces".to_string(),
335                line: 1,
336                column: 3,
337                end_line: 1,
338                end_column: 5,
339                severity: Severity::Warning,
340                fix: Some(Fix {
341                    range: 2..4,
342                    replacement: " ".to_string(),
343                }),
344                rule_name: Some("MD030".to_string()),
345            },
346            LintWarning {
347                message: "Too many spaces".to_string(),
348                line: 2,
349                column: 2,
350                end_line: 2,
351                end_column: 5,
352                severity: Severity::Warning,
353                fix: Some(Fix {
354                    range: 12..15, // Account for \r\n
355                    replacement: " ".to_string(),
356                }),
357                rule_name: Some("MD030".to_string()),
358            },
359        ];
360
361        let result = apply_warning_fixes(content, &warnings).unwrap();
362        // The implementation normalizes line endings, which may double \r
363        // Just test that the fixes were applied correctly
364        assert!(result.contains("1. First"));
365        assert!(result.contains("* Second"));
366    }
367
368    #[test]
369    fn test_apply_fix_with_invalid_range() {
370        let content = "Short";
371        let warning = LintWarning {
372            message: "Invalid fix".to_string(),
373            line: 1,
374            column: 1,
375            end_line: 1,
376            end_column: 10,
377            severity: Severity::Warning,
378            fix: Some(Fix {
379                range: 0..100, // Out of bounds
380                replacement: "Replacement".to_string(),
381            }),
382            rule_name: Some("TEST".to_string()),
383        };
384
385        let result = apply_warning_fixes(content, &[warning]);
386        assert!(result.is_err());
387        assert!(result.unwrap_err().contains("exceeds content length"));
388    }
389
390    #[test]
391    fn test_apply_fix_with_reversed_range() {
392        let content = "Hello world";
393        let warning = LintWarning {
394            message: "Invalid fix".to_string(),
395            line: 1,
396            column: 5,
397            end_line: 1,
398            end_column: 3,
399            severity: Severity::Warning,
400            fix: Some(Fix {
401                #[allow(clippy::reversed_empty_ranges)]
402                range: 10..5, // start > end - intentionally invalid for testing
403                replacement: "Test".to_string(),
404            }),
405            rule_name: Some("TEST".to_string()),
406        };
407
408        let result = apply_warning_fixes(content, &[warning]);
409        assert!(result.is_err());
410        assert!(result.unwrap_err().contains("Invalid fix range"));
411    }
412
413    #[test]
414    fn test_apply_no_fixes() {
415        let content = "No changes needed";
416        let warnings = vec![LintWarning {
417            message: "Warning without fix".to_string(),
418            line: 1,
419            column: 1,
420            end_line: 1,
421            end_column: 5,
422            severity: Severity::Warning,
423            fix: None,
424            rule_name: Some("TEST".to_string()),
425        }];
426
427        let result = apply_warning_fixes(content, &warnings).unwrap();
428        assert_eq!(result, content);
429    }
430
431    #[test]
432    fn test_overlapping_fixes_skip_outer() {
433        // Simulates nested link/image: [ ![ alt ](img) ](url) suffix
434        // Inner fix: range 2..15 (image text)
435        // Outer fix: range 0..22 (link text) — overlaps inner
436        // Only the inner (higher start) should be applied; outer is skipped.
437        let content = "[ ![ alt ](img) ](url) suffix";
438        let warnings = vec![
439            LintWarning {
440                message: "Outer link".to_string(),
441                line: 1,
442                column: 1,
443                end_line: 1,
444                end_column: 22,
445                severity: Severity::Warning,
446                fix: Some(Fix {
447                    range: 0..22,
448                    replacement: "[![alt](img)](url)".to_string(),
449                }),
450                rule_name: Some("MD039".to_string()),
451            },
452            LintWarning {
453                message: "Inner image".to_string(),
454                line: 1,
455                column: 3,
456                end_line: 1,
457                end_column: 15,
458                severity: Severity::Warning,
459                fix: Some(Fix {
460                    range: 2..15,
461                    replacement: "![alt](img)".to_string(),
462                }),
463                rule_name: Some("MD039".to_string()),
464            },
465        ];
466
467        let result = apply_warning_fixes(content, &warnings).unwrap();
468        // Inner fix applied: "![ alt ](img)" → "![alt](img)"
469        // Outer fix skipped (overlaps). Suffix preserved.
470        assert_eq!(result, "[ ![alt](img) ](url) suffix");
471    }
472
473    #[test]
474    fn test_warning_fix_to_edit() {
475        let content = "Hello world";
476        let warning = LintWarning {
477            message: "Test".to_string(),
478            line: 1,
479            column: 1,
480            end_line: 1,
481            end_column: 5,
482            severity: Severity::Warning,
483            fix: Some(Fix {
484                range: 0..5,
485                replacement: "Hi".to_string(),
486            }),
487            rule_name: Some("TEST".to_string()),
488        };
489
490        let edit = warning_fix_to_edit(content, &warning).unwrap();
491        assert_eq!(edit, (0, 5, "Hi".to_string()));
492    }
493
494    #[test]
495    fn test_warning_fix_to_edit_no_fix() {
496        let content = "Hello world";
497        let warning = LintWarning {
498            message: "Test".to_string(),
499            line: 1,
500            column: 1,
501            end_line: 1,
502            end_column: 5,
503            severity: Severity::Warning,
504            fix: None,
505            rule_name: Some("TEST".to_string()),
506        };
507
508        let result = warning_fix_to_edit(content, &warning);
509        assert!(result.is_err());
510        assert_eq!(result.unwrap_err(), "Warning has no fix");
511    }
512
513    #[test]
514    fn test_warning_fix_to_edit_invalid_range() {
515        let content = "Short";
516        let warning = LintWarning {
517            message: "Test".to_string(),
518            line: 1,
519            column: 1,
520            end_line: 1,
521            end_column: 10,
522            severity: Severity::Warning,
523            fix: Some(Fix {
524                range: 0..100,
525                replacement: "Long replacement".to_string(),
526            }),
527            rule_name: Some("TEST".to_string()),
528        };
529
530        let result = warning_fix_to_edit(content, &warning);
531        assert!(result.is_err());
532        assert!(result.unwrap_err().contains("exceeds content length"));
533    }
534
535    #[test]
536    fn test_validate_fix_range() {
537        let content = "Hello world";
538
539        // Valid range
540        let valid_fix = Fix {
541            range: 0..5,
542            replacement: "Hi".to_string(),
543        };
544        assert!(validate_fix_range(content, &valid_fix).is_ok());
545
546        // Invalid range (end > content length)
547        let invalid_fix = Fix {
548            range: 0..20,
549            replacement: "Hi".to_string(),
550        };
551        assert!(validate_fix_range(content, &invalid_fix).is_err());
552
553        // Invalid range (start > end) - create reversed range
554        let start = 5;
555        let end = 3;
556        let invalid_fix2 = Fix {
557            range: start..end,
558            replacement: "Hi".to_string(),
559        };
560        assert!(validate_fix_range(content, &invalid_fix2).is_err());
561    }
562
563    #[test]
564    fn test_validate_fix_range_edge_cases() {
565        let content = "Test";
566
567        // Empty range at start
568        let fix1 = Fix {
569            range: 0..0,
570            replacement: "Insert".to_string(),
571        };
572        assert!(validate_fix_range(content, &fix1).is_ok());
573
574        // Empty range at end
575        let fix2 = Fix {
576            range: 4..4,
577            replacement: " append".to_string(),
578        };
579        assert!(validate_fix_range(content, &fix2).is_ok());
580
581        // Full content replacement
582        let fix3 = Fix {
583            range: 0..4,
584            replacement: "Replace".to_string(),
585        };
586        assert!(validate_fix_range(content, &fix3).is_ok());
587
588        // Start exceeds content
589        let fix4 = Fix {
590            range: 10..11,
591            replacement: "Invalid".to_string(),
592        };
593        let result = validate_fix_range(content, &fix4);
594        assert!(result.is_err());
595        assert!(result.unwrap_err().contains("start 10 exceeds"));
596    }
597
598    #[test]
599    fn test_fix_ordering_stability() {
600        // Test that fixes with identical ranges maintain stable ordering
601        let content = "Test content here";
602        let warnings = vec![
603            LintWarning {
604                message: "First warning".to_string(),
605                line: 1,
606                column: 6,
607                end_line: 1,
608                end_column: 13,
609                severity: Severity::Warning,
610                fix: Some(Fix {
611                    range: 5..12, // "content"
612                    replacement: "stuff".to_string(),
613                }),
614                rule_name: Some("MD001".to_string()),
615            },
616            LintWarning {
617                message: "Second warning".to_string(),
618                line: 1,
619                column: 6,
620                end_line: 1,
621                end_column: 13,
622                severity: Severity::Warning,
623                fix: Some(Fix {
624                    range: 5..12, // Same range
625                    replacement: "stuff".to_string(),
626                }),
627                rule_name: Some("MD002".to_string()),
628            },
629        ];
630
631        // Both fixes are identical, so deduplication should leave only one
632        let result = apply_warning_fixes(content, &warnings).unwrap();
633        assert_eq!(result, "Test stuff here");
634    }
635
636    #[test]
637    fn test_line_ending_preservation() {
638        // Test Unix line endings
639        let content_unix = "Line 1\nLine 2\n";
640        let warning = LintWarning {
641            message: "Add text".to_string(),
642            line: 1,
643            column: 7,
644            end_line: 1,
645            end_column: 7,
646            severity: Severity::Warning,
647            fix: Some(Fix {
648                range: 6..6,
649                replacement: " added".to_string(),
650            }),
651            rule_name: Some("TEST".to_string()),
652        };
653
654        let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
655        assert_eq!(result, "Line 1 added\nLine 2\n");
656
657        // Test that Windows line endings work (even if normalization occurs)
658        let content_windows = "Line 1\r\nLine 2\r\n";
659        let warning_windows = LintWarning {
660            message: "Add text".to_string(),
661            line: 1,
662            column: 7,
663            end_line: 1,
664            end_column: 7,
665            severity: Severity::Warning,
666            fix: Some(Fix {
667                range: 6..6,
668                replacement: " added".to_string(),
669            }),
670            rule_name: Some("TEST".to_string()),
671        };
672
673        let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
674        // Just verify the fix was applied correctly
675        assert!(result_windows.starts_with("Line 1 added"));
676        assert!(result_windows.contains("Line 2"));
677    }
678
679    fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
680        LintWarning {
681            message: "test".to_string(),
682            line,
683            column: 1,
684            end_line,
685            end_column: 1,
686            severity: Severity::Warning,
687            fix: Some(Fix {
688                range: 0..1,
689                replacement: "x".to_string(),
690            }),
691            rule_name: Some(rule_name.to_string()),
692        }
693    }
694
695    #[test]
696    fn test_filter_warnings_disable_enable_block() {
697        let content =
698            "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
699        let inline_config = InlineConfig::from_content(content);
700
701        let warnings = vec![
702            make_warning(4, 4, "MD013"), // inside disabled block
703            make_warning(6, 6, "MD013"), // outside disabled block
704        ];
705
706        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
707        assert_eq!(filtered.len(), 1);
708        assert_eq!(filtered[0].line, 6);
709    }
710
711    #[test]
712    fn test_filter_warnings_disable_line() {
713        let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
714        let inline_config = InlineConfig::from_content(content);
715
716        let warnings = vec![
717            make_warning(1, 1, "MD009"), // disabled via disable-line
718            make_warning(2, 2, "MD009"), // not disabled
719        ];
720
721        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
722        assert_eq!(filtered.len(), 1);
723        assert_eq!(filtered[0].line, 2);
724    }
725
726    #[test]
727    fn test_filter_warnings_disable_next_line() {
728        let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
729        let inline_config = InlineConfig::from_content(content);
730
731        let warnings = vec![
732            make_warning(2, 2, "MD034"), // disabled via disable-next-line
733            make_warning(3, 3, "MD034"), // not disabled
734        ];
735
736        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
737        assert_eq!(filtered.len(), 1);
738        assert_eq!(filtered[0].line, 3);
739    }
740
741    #[test]
742    fn test_filter_warnings_sub_rule_name() {
743        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
744        let inline_config = InlineConfig::from_content(content);
745
746        // Sub-rule name like "MD029-style" should be stripped to "MD029"
747        let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
748
749        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
750        assert_eq!(filtered.len(), 1);
751        assert_eq!(filtered[0].line, 4);
752    }
753
754    #[test]
755    fn test_filter_warnings_multi_line_warning() {
756        // A warning spanning lines 3-5 where line 4 is disabled
757        let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
758        let inline_config = InlineConfig::from_content(content);
759
760        let warnings = vec![
761            make_warning(3, 5, "MD013"), // spans lines 3-5, line 4 is disabled
762            make_warning(6, 6, "MD013"), // not disabled
763        ];
764
765        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
766        // The multi-line warning should be filtered because one of its lines is disabled
767        assert_eq!(filtered.len(), 1);
768        assert_eq!(filtered[0].line, 6);
769    }
770
771    #[test]
772    fn test_filter_warnings_empty_input() {
773        let inline_config = InlineConfig::from_content("");
774        let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
775        assert!(filtered.is_empty());
776    }
777
778    #[test]
779    fn test_filter_warnings_none_disabled() {
780        let content = "line 1\nline 2\n";
781        let inline_config = InlineConfig::from_content(content);
782
783        let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
784
785        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
786        assert_eq!(filtered.len(), 2);
787    }
788
789    #[test]
790    fn test_filter_warnings_all_disabled() {
791        let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
792        let inline_config = InlineConfig::from_content(content);
793
794        let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
795
796        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
797        assert!(filtered.is_empty());
798    }
799
800    #[test]
801    fn test_filter_warnings_end_line_zero_fallback() {
802        // When end_line < line (e.g., end_line=0), should fall back to checking only warning.line
803        let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
804        let inline_config = InlineConfig::from_content(content);
805
806        let warnings = vec![make_warning(1, 0, "MD013")]; // end_line=0 < line=1
807
808        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
809        assert!(filtered.is_empty());
810    }
811
812    #[test]
813    fn test_filter_non_md_rule_name_preserves_dash() {
814        // Verify that a non-MD rule name with a dash is NOT split by the helper.
815        // The helper should pass "custom-rule" as-is to InlineConfig, not "custom".
816        let content = "line 1\nline 2\n";
817        let inline_config = InlineConfig::from_content(content);
818
819        let warnings = vec![make_warning(1, 1, "custom-rule")];
820
821        // With nothing disabled, the warning should pass through
822        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
823        assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
824    }
825
826    #[test]
827    fn test_filter_md_sub_rule_name_is_split() {
828        // Verify that "MD029-style" is split to "MD029" for inline config lookup
829        let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
830        let inline_config = InlineConfig::from_content(content);
831
832        let warnings = vec![
833            make_warning(2, 2, "MD029"), // disabled
834            make_warning(4, 4, "MD029"), // not disabled
835        ];
836
837        // Passing "MD029-style" as rule_name should still match "MD029" in inline config
838        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
839        assert_eq!(filtered.len(), 1);
840        assert_eq!(filtered[0].line, 4);
841    }
842
843    #[test]
844    fn test_filter_warnings_capture_restore() {
845        let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
846        let inline_config = InlineConfig::from_content(content);
847
848        let warnings = vec![
849            make_warning(2, 2, "MD013"), // disabled by initial disable
850            make_warning(5, 5, "MD013"), // re-enabled between capture/restore
851            make_warning(7, 7, "MD013"), // after restore, back to disabled state
852        ];
853
854        let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
855        assert_eq!(filtered.len(), 1);
856        assert_eq!(filtered[0].line, 5);
857    }
858}