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