1use crate::inline_config::InlineConfig;
7use crate::rule::{Fix, LintWarning};
8use crate::utils::ensure_consistent_line_endings;
9use std::borrow::Cow;
10use std::ops::Range;
11
12pub fn filter_warnings_by_inline_config(
17 warnings: Vec<LintWarning>,
18 inline_config: &InlineConfig,
19 rule_name: &str,
20) -> Vec<LintWarning> {
21 let base_rule_name = if let Some(dash_pos) = rule_name.find('-') {
22 let prefix = &rule_name[..dash_pos];
25 if prefix.starts_with("MD") { prefix } else { rule_name }
26 } else {
27 rule_name
28 };
29
30 warnings
31 .into_iter()
32 .filter(|w| {
33 let end = if w.end_line >= w.line { w.end_line } else { w.line };
34 !(w.line..=end).any(|line| inline_config.is_rule_disabled(base_rule_name, line))
35 })
36 .collect()
37}
38
39pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
42 let mut fixes: Vec<(usize, &Fix)> = warnings
43 .iter()
44 .enumerate()
45 .filter_map(|(i, w)| w.fix.as_ref().map(|fix| (i, fix)))
46 .flat_map(|(i, fix)| {
47 std::iter::once((i, fix)).chain(fix.additional_edits.iter().map(move |e| (i, e)))
52 })
53 .collect();
54
55 if fixes.is_empty() {
60 return Ok(content.to_string());
61 }
62
63 fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
67 let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
68 if range_cmp != std::cmp::Ordering::Equal {
69 return range_cmp;
70 }
71 let end_cmp = fix_a.range.end.cmp(&fix_b.range.end);
72 if end_cmp != std::cmp::Ordering::Equal {
73 return end_cmp;
74 }
75 idx_a.cmp(idx_b)
76 });
77
78 let mut applicable: Vec<ApplicableEdit<'_>> = Vec::with_capacity(fixes.len());
90 let mut i = 0;
91 while i < fixes.len() {
92 let (_, current) = fixes[i];
93 let mut combined: Option<String> = None;
94 let is_zero_width = current.range.start == current.range.end;
95 let mut j = i + 1;
96 while j < fixes.len() {
97 let (_, next) = fixes[j];
98 if next.range != current.range {
99 break;
100 }
101 if next.replacement == current.replacement {
102 j += 1;
104 continue;
105 }
106 if !is_zero_width {
107 break;
112 }
113 let buf = combined.get_or_insert_with(|| current.replacement.clone());
115 buf.push_str(&next.replacement);
116 j += 1;
117 }
118
119 applicable.push(ApplicableEdit {
120 range: current.range.clone(),
121 replacement: match combined {
122 Some(owned) => Cow::Owned(owned),
123 None => Cow::Borrowed(current.replacement.as_str()),
124 },
125 });
126 i = j;
127 }
128
129 applicable.sort_by(|a, b| {
133 let cmp = b.range.start.cmp(&a.range.start);
134 if cmp != std::cmp::Ordering::Equal {
135 return cmp;
136 }
137 b.range.end.cmp(&a.range.end)
138 });
139
140 let mut result = content.to_string();
141
142 let mut min_applied_start = usize::MAX;
147
148 for edit in applicable {
149 if edit.range.end > content.len() {
156 return Err(format!(
157 "Fix range end {} exceeds content length {}",
158 edit.range.end,
159 content.len()
160 ));
161 }
162
163 if edit.range.start > edit.range.end {
164 return Err(format!(
165 "Invalid fix range: start {} > end {}",
166 edit.range.start, edit.range.end
167 ));
168 }
169
170 if !content.is_char_boundary(edit.range.start) || !content.is_char_boundary(edit.range.end) {
174 return Err(format!(
175 "Fix range {}..{} does not lie on UTF-8 char boundaries",
176 edit.range.start, edit.range.end
177 ));
178 }
179
180 if edit.range.end > min_applied_start {
183 continue;
184 }
185
186 result.replace_range(edit.range.clone(), &edit.replacement);
187 min_applied_start = edit.range.start;
188 }
189
190 Ok(ensure_consistent_line_endings(content, &result))
192}
193
194struct ApplicableEdit<'a> {
198 range: Range<usize>,
199 replacement: Cow<'a, str>,
200}
201
202pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
205 if let Some(fix) = &warning.fix {
206 if fix.range.end > content.len() {
208 return Err(format!(
209 "Fix range end {} exceeds content length {}",
210 fix.range.end,
211 content.len()
212 ));
213 }
214
215 Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
216 } else {
217 Err("Warning has no fix".to_string())
218 }
219}
220
221pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
223 if fix.range.start > content.len() {
224 return Err(format!(
225 "Fix range start {} exceeds content length {}",
226 fix.range.start,
227 content.len()
228 ));
229 }
230
231 if fix.range.end > content.len() {
232 return Err(format!(
233 "Fix range end {} exceeds content length {}",
234 fix.range.end,
235 content.len()
236 ));
237 }
238
239 if fix.range.start > fix.range.end {
240 return Err(format!(
241 "Invalid fix range: start {} > end {}",
242 fix.range.start, fix.range.end
243 ));
244 }
245
246 if !content.is_char_boundary(fix.range.start) || !content.is_char_boundary(fix.range.end) {
249 return Err(format!(
250 "Fix range {}..{} does not lie on UTF-8 char boundaries",
251 fix.range.start, fix.range.end
252 ));
253 }
254
255 Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::rule::{Fix, LintWarning, Severity};
262
263 #[test]
264 fn test_validate_fix_range_rejects_non_char_boundary() {
265 let content = "é world";
267 let fix = Fix::new(1..2, "x".to_string());
268 assert!(
269 validate_fix_range(content, &fix).is_err(),
270 "validate_fix_range must reject a range that splits a codepoint"
271 );
272 let ok_fix = Fix::new(0..2, "x".to_string());
274 assert!(validate_fix_range(content, &ok_fix).is_ok());
275 }
276
277 #[test]
278 fn test_apply_single_fix() {
279 let content = "1. Multiple spaces";
280 let warning = LintWarning {
281 message: "Too many spaces".to_string(),
282 line: 1,
283 column: 3,
284 end_line: 1,
285 end_column: 5,
286 severity: Severity::Warning,
287 fix: Some(Fix::new(2..4, " ".to_string())),
288 rule_name: Some("MD030".to_string()),
289 };
290
291 let result = apply_warning_fixes(content, &[warning]).unwrap();
292 assert_eq!(result, "1. Multiple spaces");
293 }
294
295 #[test]
296 fn test_apply_fix_with_non_char_boundary_range_does_not_panic() {
297 let content = "é world";
301 let warning = LintWarning {
302 message: "bad range".to_string(),
303 line: 1,
304 column: 1,
305 end_line: 1,
306 end_column: 2,
307 severity: Severity::Warning,
308 fix: Some(Fix::new(1..2, "x".to_string())),
309 rule_name: Some("MDTEST".to_string()),
310 };
311
312 let result = apply_warning_fixes(content, &[warning]);
313 assert!(
314 result.is_err(),
315 "non-char-boundary range must be rejected, got {result:?}"
316 );
317 }
318
319 #[test]
320 fn test_apply_multiple_fixes() {
321 let content = "1. First\n* Second";
322 let warnings = vec![
323 LintWarning {
324 message: "Too many spaces".to_string(),
325 line: 1,
326 column: 3,
327 end_line: 1,
328 end_column: 5,
329 severity: Severity::Warning,
330 fix: Some(Fix::new(2..4, " ".to_string())),
331 rule_name: Some("MD030".to_string()),
332 },
333 LintWarning {
334 message: "Too many spaces".to_string(),
335 line: 2,
336 column: 2,
337 end_line: 2,
338 end_column: 5,
339 severity: Severity::Warning,
340 fix: Some(Fix::new(11..14, " ".to_string())),
341 rule_name: Some("MD030".to_string()),
342 },
343 ];
344
345 let result = apply_warning_fixes(content, &warnings).unwrap();
346 assert_eq!(result, "1. First\n* Second");
347 }
348
349 #[test]
350 fn test_apply_non_overlapping_fixes() {
351 let content = "Test multiple spaces";
356 let warnings = vec![
357 LintWarning {
358 message: "Too many spaces".to_string(),
359 line: 1,
360 column: 5,
361 end_line: 1,
362 end_column: 7,
363 severity: Severity::Warning,
364 fix: Some(Fix::new(4..6, " ".to_string())),
365 rule_name: Some("MD009".to_string()),
366 },
367 LintWarning {
368 message: "Too many spaces".to_string(),
369 line: 1,
370 column: 15,
371 end_line: 1,
372 end_column: 19,
373 severity: Severity::Warning,
374 fix: Some(Fix::new(14..18, " ".to_string())),
375 rule_name: Some("MD009".to_string()),
376 },
377 ];
378
379 let result = apply_warning_fixes(content, &warnings).unwrap();
380 assert_eq!(result, "Test multiple spaces");
381 }
382
383 #[test]
384 fn test_apply_duplicate_fixes() {
385 let content = "Test content";
386 let warnings = vec![
387 LintWarning {
388 message: "Fix 1".to_string(),
389 line: 1,
390 column: 5,
391 end_line: 1,
392 end_column: 7,
393 severity: Severity::Warning,
394 fix: Some(Fix::new(4..6, " ".to_string())),
395 rule_name: Some("MD009".to_string()),
396 },
397 LintWarning {
398 message: "Fix 2 (duplicate)".to_string(),
399 line: 1,
400 column: 5,
401 end_line: 1,
402 end_column: 7,
403 severity: Severity::Warning,
404 fix: Some(Fix::new(4..6, " ".to_string())),
405 rule_name: Some("MD009".to_string()),
406 },
407 ];
408
409 let result = apply_warning_fixes(content, &warnings).unwrap();
411 assert_eq!(result, "Test content");
412 }
413
414 #[test]
415 fn test_apply_fixes_with_windows_line_endings() {
416 let content = "1. First\r\n* Second\r\n";
417 let warnings = vec![
418 LintWarning {
419 message: "Too many spaces".to_string(),
420 line: 1,
421 column: 3,
422 end_line: 1,
423 end_column: 5,
424 severity: Severity::Warning,
425 fix: Some(Fix::new(2..4, " ".to_string())),
426 rule_name: Some("MD030".to_string()),
427 },
428 LintWarning {
429 message: "Too many spaces".to_string(),
430 line: 2,
431 column: 2,
432 end_line: 2,
433 end_column: 5,
434 severity: Severity::Warning,
435 fix: Some(Fix::new(12..15, " ".to_string())),
436 rule_name: Some("MD030".to_string()),
437 },
438 ];
439
440 let result = apply_warning_fixes(content, &warnings).unwrap();
441 assert!(result.contains("1. First"));
444 assert!(result.contains("* Second"));
445 }
446
447 #[test]
448 fn test_apply_fix_with_invalid_range() {
449 let content = "Short";
450 let warning = LintWarning {
451 message: "Invalid fix".to_string(),
452 line: 1,
453 column: 1,
454 end_line: 1,
455 end_column: 10,
456 severity: Severity::Warning,
457 fix: Some(Fix::new(0..100, "Replacement".to_string())),
458 rule_name: Some("TEST".to_string()),
459 };
460
461 let result = apply_warning_fixes(content, &[warning]);
462 assert!(result.is_err());
463 assert!(result.unwrap_err().contains("exceeds content length"));
464 }
465
466 #[test]
467 #[allow(clippy::reversed_empty_ranges)]
468 fn test_apply_fix_with_reversed_range() {
469 let content = "Hello world";
470 let warning = LintWarning {
471 message: "Invalid fix".to_string(),
472 line: 1,
473 column: 5,
474 end_line: 1,
475 end_column: 3,
476 severity: Severity::Warning,
477 fix: Some(Fix::new(10..5, "Test".to_string())),
478 rule_name: Some("TEST".to_string()),
479 };
480
481 let result = apply_warning_fixes(content, &[warning]);
482 assert!(result.is_err());
483 assert!(result.unwrap_err().contains("Invalid fix range"));
484 }
485
486 #[test]
487 fn test_apply_no_fixes() {
488 let content = "No changes needed";
489 let warnings = vec![LintWarning {
490 message: "Warning without fix".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 = apply_warning_fixes(content, &warnings).unwrap();
501 assert_eq!(result, content);
502 }
503
504 #[test]
505 fn test_overlapping_fixes_skip_outer() {
506 let content = "[  ](url) suffix";
511 let warnings = vec](url)".to_string())),
520 rule_name: Some("MD039".to_string()),
521 },
522 LintWarning {
523 message: "Inner image".to_string(),
524 line: 1,
525 column: 3,
526 end_line: 1,
527 end_column: 15,
528 severity: Severity::Warning,
529 fix: Some(Fix::new(2..15, "".to_string())),
530 rule_name: Some("MD039".to_string()),
531 },
532 ];
533
534 let result = apply_warning_fixes(content, &warnings).unwrap();
535 assert_eq!(result, "[  ](url) suffix");
538 }
539
540 #[test]
541 fn test_overlapping_outer_fix_is_skipped_without_trailing_slack() {
542 let content = "[ a  b ](t.md)\n";
549 let warnings = vec b](t.md)".to_string())),
558 rule_name: Some("MD039".to_string()),
559 },
560 LintWarning {
561 message: "Inner image".to_string(),
562 line: 1,
563 column: 5,
564 end_line: 1,
565 end_column: 18,
566 severity: Severity::Warning,
567 fix: Some(Fix::new(4..17, "".to_string())),
568 rule_name: Some("MD039".to_string()),
569 },
570 ];
571
572 let result = apply_warning_fixes(content, &warnings).unwrap();
573 assert_eq!(result, "[ a  b ](t.md)\n");
574 }
575
576 #[test]
577 fn test_out_of_bounds_fix_is_still_rejected() {
578 let content = "short\n";
582 let warnings = vec![LintWarning {
583 message: "Past the end".to_string(),
584 line: 1,
585 column: 1,
586 end_line: 1,
587 end_column: 1,
588 severity: Severity::Warning,
589 fix: Some(Fix::new(0..99, "x".to_string())),
590 rule_name: Some("MDTEST".to_string()),
591 }];
592
593 let err = apply_warning_fixes(content, &warnings).expect_err("out-of-bounds range must be rejected");
597 assert_eq!(err, "Fix range end 99 exceeds content length 6");
598 }
599
600 #[test]
601 fn test_apply_fix_with_additional_edits_atomic() {
602 let content = "See [docs](https://example.com) for details.\n";
607 let primary_range = content.find("[docs](https://example.com)").unwrap()..content.find(" for details").unwrap();
608 let appended = "\n[docs]: https://example.com\n".to_string();
609 let warnings = vec![LintWarning {
610 message: "Inconsistent link style".to_string(),
611 line: 1,
612 column: 5,
613 end_line: 1,
614 end_column: 32,
615 severity: Severity::Warning,
616 fix: Some(Fix::with_additional_edits(
617 primary_range,
618 "[docs]".to_string(),
619 vec![Fix::new(content.len()..content.len(), appended)],
620 )),
621 rule_name: Some("MD054".to_string()),
622 }];
623
624 let result = apply_warning_fixes(content, &warnings).unwrap();
625 assert!(
626 result.contains("See [docs] for details."),
627 "primary edit must rewrite the inline link in place: {result:?}"
628 );
629 assert!(
630 result.contains("[docs]: https://example.com"),
631 "additional edit must append the ref-def at EOF: {result:?}"
632 );
633 assert!(
634 !result.contains("[docs](https://example.com)"),
635 "the inline form must be gone after the atomic fix: {result:?}"
636 );
637 }
638
639 #[test]
640 fn test_apply_two_ref_emit_fixes_preserve_source_order() {
641 let content = "See [a](https://a.com) and [b](https://b.com).\n";
654 let span_a = content.find("[a](https://a.com)").unwrap()
655 ..content.find("](https://a.com)").unwrap() + "](https://a.com)".len();
656 let span_b = content.find("[b](https://b.com)").unwrap()
657 ..content.find("](https://b.com)").unwrap() + "](https://b.com)".len();
658 let warnings = vec![
659 LintWarning {
660 message: "Inconsistent link style".to_string(),
661 line: 1,
662 column: 5,
663 end_line: 1,
664 end_column: 0,
665 severity: Severity::Warning,
666 fix: Some(Fix::with_additional_edits(
667 span_a,
668 "[a]".to_string(),
669 vec![Fix::new(
670 content.len()..content.len(),
671 "[a]: https://a.com\n".to_string(),
672 )],
673 )),
674 rule_name: Some("MD054".to_string()),
675 },
676 LintWarning {
677 message: "Inconsistent link style".to_string(),
678 line: 1,
679 column: 28,
680 end_line: 1,
681 end_column: 0,
682 severity: Severity::Warning,
683 fix: Some(Fix::with_additional_edits(
684 span_b,
685 "[b]".to_string(),
686 vec![Fix::new(
687 content.len()..content.len(),
688 "[b]: https://b.com\n".to_string(),
689 )],
690 )),
691 rule_name: Some("MD054".to_string()),
692 },
693 ];
694
695 let result = apply_warning_fixes(content, &warnings).unwrap();
696
697 assert!(
699 result.contains("See [a] and [b]."),
700 "primary rewrites missing: {result:?}"
701 );
702 assert!(!result.contains("[a](https://a.com)"));
703 assert!(!result.contains("[b](https://b.com)"));
704
705 let pos_a = result.find("[a]: https://a.com").expect("ref-def for [a] missing");
707 let pos_b = result.find("[b]: https://b.com").expect("ref-def for [b] missing");
708 assert!(
709 pos_a < pos_b,
710 "ref-defs must appear in source order ([a] before [b]); got result:\n{result}"
711 );
712 }
713
714 #[test]
715 fn test_warning_fix_to_edit() {
716 let content = "Hello world";
717 let warning = LintWarning {
718 message: "Test".to_string(),
719 line: 1,
720 column: 1,
721 end_line: 1,
722 end_column: 5,
723 severity: Severity::Warning,
724 fix: Some(Fix::new(0..5, "Hi".to_string())),
725 rule_name: Some("TEST".to_string()),
726 };
727
728 let edit = warning_fix_to_edit(content, &warning).unwrap();
729 assert_eq!(edit, (0, 5, "Hi".to_string()));
730 }
731
732 #[test]
733 fn test_warning_fix_to_edit_no_fix() {
734 let content = "Hello world";
735 let warning = LintWarning {
736 message: "Test".to_string(),
737 line: 1,
738 column: 1,
739 end_line: 1,
740 end_column: 5,
741 severity: Severity::Warning,
742 fix: None,
743 rule_name: Some("TEST".to_string()),
744 };
745
746 let result = warning_fix_to_edit(content, &warning);
747 assert!(result.is_err());
748 assert_eq!(result.unwrap_err(), "Warning has no fix");
749 }
750
751 #[test]
752 fn test_warning_fix_to_edit_invalid_range() {
753 let content = "Short";
754 let warning = LintWarning {
755 message: "Test".to_string(),
756 line: 1,
757 column: 1,
758 end_line: 1,
759 end_column: 10,
760 severity: Severity::Warning,
761 fix: Some(Fix::new(0..100, "Long replacement".to_string())),
762 rule_name: Some("TEST".to_string()),
763 };
764
765 let result = warning_fix_to_edit(content, &warning);
766 assert!(result.is_err());
767 assert!(result.unwrap_err().contains("exceeds content length"));
768 }
769
770 #[test]
771 fn test_validate_fix_range() {
772 let content = "Hello world";
773
774 let valid_fix = Fix::new(0..5, "Hi".to_string());
776 assert!(validate_fix_range(content, &valid_fix).is_ok());
777
778 let invalid_fix = Fix::new(0..20, "Hi".to_string());
780 assert!(validate_fix_range(content, &invalid_fix).is_err());
781
782 let start = 5;
784 let end = 3;
785 let invalid_fix2 = Fix::new(start..end, "Hi".to_string());
786 assert!(validate_fix_range(content, &invalid_fix2).is_err());
787 }
788
789 #[test]
790 fn test_validate_fix_range_edge_cases() {
791 let content = "Test";
792
793 let fix1 = Fix::new(0..0, "Insert".to_string());
795 assert!(validate_fix_range(content, &fix1).is_ok());
796
797 let fix2 = Fix::new(4..4, " append".to_string());
799 assert!(validate_fix_range(content, &fix2).is_ok());
800
801 let fix3 = Fix::new(0..4, "Replace".to_string());
803 assert!(validate_fix_range(content, &fix3).is_ok());
804
805 let fix4 = Fix::new(10..11, "Invalid".to_string());
807 let result = validate_fix_range(content, &fix4);
808 assert!(result.is_err());
809 assert!(result.unwrap_err().contains("start 10 exceeds"));
810 }
811
812 #[test]
813 fn test_fix_ordering_stability() {
814 let content = "Test content here";
816 let warnings = vec![
817 LintWarning {
818 message: "First warning".to_string(),
819 line: 1,
820 column: 6,
821 end_line: 1,
822 end_column: 13,
823 severity: Severity::Warning,
824 fix: Some(Fix::new(5..12, "stuff".to_string())),
825 rule_name: Some("MD001".to_string()),
826 },
827 LintWarning {
828 message: "Second warning".to_string(),
829 line: 1,
830 column: 6,
831 end_line: 1,
832 end_column: 13,
833 severity: Severity::Warning,
834 fix: Some(Fix::new(5..12, "stuff".to_string())),
835 rule_name: Some("MD002".to_string()),
836 },
837 ];
838
839 let result = apply_warning_fixes(content, &warnings).unwrap();
841 assert_eq!(result, "Test stuff here");
842 }
843
844 #[test]
845 fn test_line_ending_preservation() {
846 let content_unix = "Line 1\nLine 2\n";
848 let warning = LintWarning {
849 message: "Add text".to_string(),
850 line: 1,
851 column: 7,
852 end_line: 1,
853 end_column: 7,
854 severity: Severity::Warning,
855 fix: Some(Fix::new(6..6, " added".to_string())),
856 rule_name: Some("TEST".to_string()),
857 };
858
859 let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
860 assert_eq!(result, "Line 1 added\nLine 2\n");
861
862 let content_windows = "Line 1\r\nLine 2\r\n";
864 let warning_windows = LintWarning {
865 message: "Add text".to_string(),
866 line: 1,
867 column: 7,
868 end_line: 1,
869 end_column: 7,
870 severity: Severity::Warning,
871 fix: Some(Fix::new(6..6, " added".to_string())),
872 rule_name: Some("TEST".to_string()),
873 };
874
875 let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
876 assert!(result_windows.starts_with("Line 1 added"));
878 assert!(result_windows.contains("Line 2"));
879 }
880
881 fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
882 LintWarning {
883 message: "test".to_string(),
884 line,
885 column: 1,
886 end_line,
887 end_column: 1,
888 severity: Severity::Warning,
889 fix: Some(Fix::new(0..1, "x".to_string())),
890 rule_name: Some(rule_name.to_string()),
891 }
892 }
893
894 #[test]
895 fn test_filter_warnings_disable_enable_block() {
896 let content =
897 "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
898 let inline_config = InlineConfig::from_content(content);
899
900 let warnings = vec![
901 make_warning(4, 4, "MD013"), make_warning(6, 6, "MD013"), ];
904
905 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
906 assert_eq!(filtered.len(), 1);
907 assert_eq!(filtered[0].line, 6);
908 }
909
910 #[test]
911 fn test_filter_warnings_disable_line() {
912 let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
913 let inline_config = InlineConfig::from_content(content);
914
915 let warnings = vec![
916 make_warning(1, 1, "MD009"), make_warning(2, 2, "MD009"), ];
919
920 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
921 assert_eq!(filtered.len(), 1);
922 assert_eq!(filtered[0].line, 2);
923 }
924
925 #[test]
926 fn test_filter_warnings_disable_next_line() {
927 let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
928 let inline_config = InlineConfig::from_content(content);
929
930 let warnings = vec![
931 make_warning(2, 2, "MD034"), make_warning(3, 3, "MD034"), ];
934
935 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
936 assert_eq!(filtered.len(), 1);
937 assert_eq!(filtered[0].line, 3);
938 }
939
940 #[test]
941 fn test_filter_warnings_sub_rule_name() {
942 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
943 let inline_config = InlineConfig::from_content(content);
944
945 let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
947
948 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
949 assert_eq!(filtered.len(), 1);
950 assert_eq!(filtered[0].line, 4);
951 }
952
953 #[test]
954 fn test_filter_warnings_multi_line_warning() {
955 let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
957 let inline_config = InlineConfig::from_content(content);
958
959 let warnings = vec![
960 make_warning(3, 5, "MD013"), make_warning(6, 6, "MD013"), ];
963
964 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
965 assert_eq!(filtered.len(), 1);
967 assert_eq!(filtered[0].line, 6);
968 }
969
970 #[test]
971 fn test_filter_warnings_empty_input() {
972 let inline_config = InlineConfig::from_content("");
973 let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
974 assert!(filtered.is_empty());
975 }
976
977 #[test]
978 fn test_filter_warnings_none_disabled() {
979 let content = "line 1\nline 2\n";
980 let inline_config = InlineConfig::from_content(content);
981
982 let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
983
984 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
985 assert_eq!(filtered.len(), 2);
986 }
987
988 #[test]
989 fn test_filter_warnings_all_disabled() {
990 let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
991 let inline_config = InlineConfig::from_content(content);
992
993 let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
994
995 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
996 assert!(filtered.is_empty());
997 }
998
999 #[test]
1000 fn test_filter_warnings_end_line_zero_fallback() {
1001 let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
1003 let inline_config = InlineConfig::from_content(content);
1004
1005 let warnings = vec![make_warning(1, 0, "MD013")]; let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1008 assert!(filtered.is_empty());
1009 }
1010
1011 #[test]
1012 fn test_filter_non_md_rule_name_preserves_dash() {
1013 let content = "line 1\nline 2\n";
1016 let inline_config = InlineConfig::from_content(content);
1017
1018 let warnings = vec![make_warning(1, 1, "custom-rule")];
1019
1020 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
1022 assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
1023 }
1024
1025 #[test]
1026 fn test_filter_md_sub_rule_name_is_split() {
1027 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
1029 let inline_config = InlineConfig::from_content(content);
1030
1031 let warnings = vec![
1032 make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029"), ];
1035
1036 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
1038 assert_eq!(filtered.len(), 1);
1039 assert_eq!(filtered[0].line, 4);
1040 }
1041
1042 #[test]
1043 fn test_filter_warnings_capture_restore() {
1044 let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
1045 let inline_config = InlineConfig::from_content(content);
1046
1047 let warnings = vec![
1048 make_warning(2, 2, "MD013"), make_warning(5, 5, "MD013"), make_warning(7, 7, "MD013"), ];
1052
1053 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1054 assert_eq!(filtered.len(), 1);
1055 assert_eq!(filtered[0].line, 5);
1056 }
1057}