1use crate::inline_config::InlineConfig;
7use crate::rule::{Fix, LintWarning};
8use crate::utils::ensure_consistent_line_endings;
9
10pub 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 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
37pub 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 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 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; } else {
68 break;
69 }
70 }
71 i += 1;
72 }
73
74 let mut fixes = deduplicated;
75
76 fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
79 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 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 idx_a.cmp(idx_b)
93 });
94
95 let mut result = content.to_string();
96
97 let mut min_applied_start = usize::MAX;
102
103 for (_, fix) in fixes {
104 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 if fix.range.end > min_applied_start {
123 continue;
124 }
125
126 result.replace_range(fix.range.clone(), &fix.replacement);
128 min_applied_start = fix.range.start;
129 }
130
131 Ok(ensure_consistent_line_endings(content, &result))
133}
134
135pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
138 if let Some(fix) = &warning.fix {
139 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
154pub 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, replacement: " ".to_string(), }),
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, 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, 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 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, 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, 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 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, replacement: " ".to_string(),
348 }),
349 rule_name: Some("MD030".to_string()),
350 },
351 ];
352
353 let result = apply_warning_fixes(content, &warnings).unwrap();
354 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, 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, 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 let content = "[  ](url) suffix";
430 let warnings = vec](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: "".to_string(),
454 }),
455 rule_name: Some("MD039".to_string()),
456 },
457 ];
458
459 let result = apply_warning_fixes(content, &warnings).unwrap();
460 assert_eq!(result, "[  ](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 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 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 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 let fix1 = Fix {
561 range: 0..0,
562 replacement: "Insert".to_string(),
563 };
564 assert!(validate_fix_range(content, &fix1).is_ok());
565
566 let fix2 = Fix {
568 range: 4..4,
569 replacement: " append".to_string(),
570 };
571 assert!(validate_fix_range(content, &fix2).is_ok());
572
573 let fix3 = Fix {
575 range: 0..4,
576 replacement: "Replace".to_string(),
577 };
578 assert!(validate_fix_range(content, &fix3).is_ok());
579
580 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 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, 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, replacement: "stuff".to_string(),
618 }),
619 rule_name: Some("MD002".to_string()),
620 },
621 ];
622
623 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 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 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 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"), make_warning(6, 6, "MD013"), ];
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"), make_warning(2, 2, "MD009"), ];
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"), make_warning(3, 3, "MD034"), ];
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 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 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"), make_warning(6, 6, "MD013"), ];
756
757 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
758 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 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")]; 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 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 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 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"), make_warning(4, 4, "MD029"), ];
828
829 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"), make_warning(5, 5, "MD013"), make_warning(7, 7, "MD013"), ];
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}