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 kept: Vec<ApplicableEdit<'_>> = Vec::with_capacity(applicable.len());
144
145 let mut min_applied_start = usize::MAX;
150
151 for edit in applicable {
152 if edit.range.end > content.len() {
159 return Err(format!(
160 "Fix range end {} exceeds content length {}",
161 edit.range.end,
162 content.len()
163 ));
164 }
165
166 if edit.range.start > edit.range.end {
167 return Err(format!(
168 "Invalid fix range: start {} > end {}",
169 edit.range.start, edit.range.end
170 ));
171 }
172
173 if !content.is_char_boundary(edit.range.start) || !content.is_char_boundary(edit.range.end) {
177 return Err(format!(
178 "Fix range {}..{} does not lie on UTF-8 char boundaries",
179 edit.range.start, edit.range.end
180 ));
181 }
182
183 if edit.range.end > min_applied_start {
186 continue;
187 }
188
189 min_applied_start = edit.range.start;
190 kept.push(edit);
191 }
192
193 let growth: usize = kept.iter().map(|edit| edit.replacement.len()).sum();
194 let mut result = String::with_capacity(content.len() + growth);
195 let mut copied = 0;
196 for edit in kept.iter().rev() {
197 result.push_str(&content[copied..edit.range.start]);
198 result.push_str(&edit.replacement);
199 copied = edit.range.end;
200 }
201 result.push_str(&content[copied..]);
202
203 Ok(ensure_consistent_line_endings(content, &result))
205}
206
207struct ApplicableEdit<'a> {
211 range: Range<usize>,
212 replacement: Cow<'a, str>,
213}
214
215pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
218 if let Some(fix) = &warning.fix {
219 if fix.range.end > content.len() {
221 return Err(format!(
222 "Fix range end {} exceeds content length {}",
223 fix.range.end,
224 content.len()
225 ));
226 }
227
228 Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
229 } else {
230 Err("Warning has no fix".to_string())
231 }
232}
233
234pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
236 if fix.range.start > content.len() {
237 return Err(format!(
238 "Fix range start {} exceeds content length {}",
239 fix.range.start,
240 content.len()
241 ));
242 }
243
244 if fix.range.end > content.len() {
245 return Err(format!(
246 "Fix range end {} exceeds content length {}",
247 fix.range.end,
248 content.len()
249 ));
250 }
251
252 if fix.range.start > fix.range.end {
253 return Err(format!(
254 "Invalid fix range: start {} > end {}",
255 fix.range.start, fix.range.end
256 ));
257 }
258
259 if !content.is_char_boundary(fix.range.start) || !content.is_char_boundary(fix.range.end) {
262 return Err(format!(
263 "Fix range {}..{} does not lie on UTF-8 char boundaries",
264 fix.range.start, fix.range.end
265 ));
266 }
267
268 Ok(())
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::rule::{Fix, LintWarning, Severity};
275
276 #[test]
277 fn test_validate_fix_range_rejects_non_char_boundary() {
278 let content = "é world";
280 let fix = Fix::new(1..2, "x".to_string());
281 assert!(
282 validate_fix_range(content, &fix).is_err(),
283 "validate_fix_range must reject a range that splits a codepoint"
284 );
285 let ok_fix = Fix::new(0..2, "x".to_string());
287 assert!(validate_fix_range(content, &ok_fix).is_ok());
288 }
289
290 #[test]
291 fn test_apply_single_fix() {
292 let content = "1. Multiple spaces";
293 let warning = LintWarning {
294 message: "Too many spaces".to_string(),
295 line: 1,
296 column: 3,
297 end_line: 1,
298 end_column: 5,
299 severity: Severity::Warning,
300 fix: Some(Fix::new(2..4, " ".to_string())),
301 rule_name: Some("MD030".to_string()),
302 };
303
304 let result = apply_warning_fixes(content, &[warning]).unwrap();
305 assert_eq!(result, "1. Multiple spaces");
306 }
307
308 #[test]
309 fn test_apply_fix_with_non_char_boundary_range_does_not_panic() {
310 let content = "é world";
314 let warning = LintWarning {
315 message: "bad range".to_string(),
316 line: 1,
317 column: 1,
318 end_line: 1,
319 end_column: 2,
320 severity: Severity::Warning,
321 fix: Some(Fix::new(1..2, "x".to_string())),
322 rule_name: Some("MDTEST".to_string()),
323 };
324
325 let result = apply_warning_fixes(content, &[warning]);
326 assert!(
327 result.is_err(),
328 "non-char-boundary range must be rejected, got {result:?}"
329 );
330 }
331
332 #[test]
333 fn test_apply_multiple_fixes() {
334 let content = "1. First\n* Second";
335 let warnings = vec![
336 LintWarning {
337 message: "Too many spaces".to_string(),
338 line: 1,
339 column: 3,
340 end_line: 1,
341 end_column: 5,
342 severity: Severity::Warning,
343 fix: Some(Fix::new(2..4, " ".to_string())),
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::new(11..14, " ".to_string())),
354 rule_name: Some("MD030".to_string()),
355 },
356 ];
357
358 let result = apply_warning_fixes(content, &warnings).unwrap();
359 assert_eq!(result, "1. First\n* Second");
360 }
361
362 #[test]
363 fn test_apply_non_overlapping_fixes() {
364 let content = "Test multiple spaces";
369 let warnings = vec![
370 LintWarning {
371 message: "Too many spaces".to_string(),
372 line: 1,
373 column: 5,
374 end_line: 1,
375 end_column: 7,
376 severity: Severity::Warning,
377 fix: Some(Fix::new(4..6, " ".to_string())),
378 rule_name: Some("MD009".to_string()),
379 },
380 LintWarning {
381 message: "Too many spaces".to_string(),
382 line: 1,
383 column: 15,
384 end_line: 1,
385 end_column: 19,
386 severity: Severity::Warning,
387 fix: Some(Fix::new(14..18, " ".to_string())),
388 rule_name: Some("MD009".to_string()),
389 },
390 ];
391
392 let result = apply_warning_fixes(content, &warnings).unwrap();
393 assert_eq!(result, "Test multiple spaces");
394 }
395
396 #[test]
397 fn test_apply_duplicate_fixes() {
398 let content = "Test content";
399 let warnings = vec![
400 LintWarning {
401 message: "Fix 1".to_string(),
402 line: 1,
403 column: 5,
404 end_line: 1,
405 end_column: 7,
406 severity: Severity::Warning,
407 fix: Some(Fix::new(4..6, " ".to_string())),
408 rule_name: Some("MD009".to_string()),
409 },
410 LintWarning {
411 message: "Fix 2 (duplicate)".to_string(),
412 line: 1,
413 column: 5,
414 end_line: 1,
415 end_column: 7,
416 severity: Severity::Warning,
417 fix: Some(Fix::new(4..6, " ".to_string())),
418 rule_name: Some("MD009".to_string()),
419 },
420 ];
421
422 let result = apply_warning_fixes(content, &warnings).unwrap();
424 assert_eq!(result, "Test content");
425 }
426
427 #[test]
428 fn test_apply_fixes_with_windows_line_endings() {
429 let content = "1. First\r\n* Second\r\n";
430 let warnings = vec![
431 LintWarning {
432 message: "Too many spaces".to_string(),
433 line: 1,
434 column: 3,
435 end_line: 1,
436 end_column: 5,
437 severity: Severity::Warning,
438 fix: Some(Fix::new(2..4, " ".to_string())),
439 rule_name: Some("MD030".to_string()),
440 },
441 LintWarning {
442 message: "Too many spaces".to_string(),
443 line: 2,
444 column: 2,
445 end_line: 2,
446 end_column: 5,
447 severity: Severity::Warning,
448 fix: Some(Fix::new(12..15, " ".to_string())),
449 rule_name: Some("MD030".to_string()),
450 },
451 ];
452
453 let result = apply_warning_fixes(content, &warnings).unwrap();
454 assert!(result.contains("1. First"));
457 assert!(result.contains("* Second"));
458 }
459
460 #[test]
461 fn test_apply_fix_with_invalid_range() {
462 let content = "Short";
463 let warning = LintWarning {
464 message: "Invalid fix".to_string(),
465 line: 1,
466 column: 1,
467 end_line: 1,
468 end_column: 10,
469 severity: Severity::Warning,
470 fix: Some(Fix::new(0..100, "Replacement".to_string())),
471 rule_name: Some("TEST".to_string()),
472 };
473
474 let result = apply_warning_fixes(content, &[warning]);
475 assert!(result.is_err());
476 assert!(result.unwrap_err().contains("exceeds content length"));
477 }
478
479 #[test]
480 #[allow(clippy::reversed_empty_ranges)]
481 fn test_apply_fix_with_reversed_range() {
482 let content = "Hello world";
483 let warning = LintWarning {
484 message: "Invalid fix".to_string(),
485 line: 1,
486 column: 5,
487 end_line: 1,
488 end_column: 3,
489 severity: Severity::Warning,
490 fix: Some(Fix::new(10..5, "Test".to_string())),
491 rule_name: Some("TEST".to_string()),
492 };
493
494 let result = apply_warning_fixes(content, &[warning]);
495 assert!(result.is_err());
496 assert!(result.unwrap_err().contains("Invalid fix range"));
497 }
498
499 #[test]
500 fn test_apply_no_fixes() {
501 let content = "No changes needed";
502 let warnings = vec![LintWarning {
503 message: "Warning without fix".to_string(),
504 line: 1,
505 column: 1,
506 end_line: 1,
507 end_column: 5,
508 severity: Severity::Warning,
509 fix: None,
510 rule_name: Some("TEST".to_string()),
511 }];
512
513 let result = apply_warning_fixes(content, &warnings).unwrap();
514 assert_eq!(result, content);
515 }
516
517 #[test]
518 fn test_overlapping_fixes_skip_outer() {
519 let content = "[  ](url) suffix";
524 let warnings = vec](url)".to_string())),
533 rule_name: Some("MD039".to_string()),
534 },
535 LintWarning {
536 message: "Inner image".to_string(),
537 line: 1,
538 column: 3,
539 end_line: 1,
540 end_column: 15,
541 severity: Severity::Warning,
542 fix: Some(Fix::new(2..15, "".to_string())),
543 rule_name: Some("MD039".to_string()),
544 },
545 ];
546
547 let result = apply_warning_fixes(content, &warnings).unwrap();
548 assert_eq!(result, "[  ](url) suffix");
551 }
552
553 #[test]
554 fn test_overlapping_outer_fix_is_skipped_without_trailing_slack() {
555 let content = "[ a  b ](t.md)\n";
562 let warnings = vec b](t.md)".to_string())),
571 rule_name: Some("MD039".to_string()),
572 },
573 LintWarning {
574 message: "Inner image".to_string(),
575 line: 1,
576 column: 5,
577 end_line: 1,
578 end_column: 18,
579 severity: Severity::Warning,
580 fix: Some(Fix::new(4..17, "".to_string())),
581 rule_name: Some("MD039".to_string()),
582 },
583 ];
584
585 let result = apply_warning_fixes(content, &warnings).unwrap();
586 assert_eq!(result, "[ a  b ](t.md)\n");
587 }
588
589 #[test]
590 fn test_out_of_bounds_fix_is_still_rejected() {
591 let content = "short\n";
595 let warnings = vec![LintWarning {
596 message: "Past the end".to_string(),
597 line: 1,
598 column: 1,
599 end_line: 1,
600 end_column: 1,
601 severity: Severity::Warning,
602 fix: Some(Fix::new(0..99, "x".to_string())),
603 rule_name: Some("MDTEST".to_string()),
604 }];
605
606 let err = apply_warning_fixes(content, &warnings).expect_err("out-of-bounds range must be rejected");
610 assert_eq!(err, "Fix range end 99 exceeds content length 6");
611 }
612
613 #[test]
614 fn test_apply_fix_with_additional_edits_atomic() {
615 let content = "See [docs](https://example.com) for details.\n";
620 let primary_range = content.find("[docs](https://example.com)").unwrap()..content.find(" for details").unwrap();
621 let appended = "\n[docs]: https://example.com\n".to_string();
622 let warnings = vec![LintWarning {
623 message: "Inconsistent link style".to_string(),
624 line: 1,
625 column: 5,
626 end_line: 1,
627 end_column: 32,
628 severity: Severity::Warning,
629 fix: Some(Fix::with_additional_edits(
630 primary_range,
631 "[docs]".to_string(),
632 vec![Fix::new(content.len()..content.len(), appended)],
633 )),
634 rule_name: Some("MD054".to_string()),
635 }];
636
637 let result = apply_warning_fixes(content, &warnings).unwrap();
638 assert!(
639 result.contains("See [docs] for details."),
640 "primary edit must rewrite the inline link in place: {result:?}"
641 );
642 assert!(
643 result.contains("[docs]: https://example.com"),
644 "additional edit must append the ref-def at EOF: {result:?}"
645 );
646 assert!(
647 !result.contains("[docs](https://example.com)"),
648 "the inline form must be gone after the atomic fix: {result:?}"
649 );
650 }
651
652 #[test]
653 fn test_apply_two_ref_emit_fixes_preserve_source_order() {
654 let content = "See [a](https://a.com) and [b](https://b.com).\n";
667 let span_a = content.find("[a](https://a.com)").unwrap()
668 ..content.find("](https://a.com)").unwrap() + "](https://a.com)".len();
669 let span_b = content.find("[b](https://b.com)").unwrap()
670 ..content.find("](https://b.com)").unwrap() + "](https://b.com)".len();
671 let warnings = vec![
672 LintWarning {
673 message: "Inconsistent link style".to_string(),
674 line: 1,
675 column: 5,
676 end_line: 1,
677 end_column: 0,
678 severity: Severity::Warning,
679 fix: Some(Fix::with_additional_edits(
680 span_a,
681 "[a]".to_string(),
682 vec![Fix::new(
683 content.len()..content.len(),
684 "[a]: https://a.com\n".to_string(),
685 )],
686 )),
687 rule_name: Some("MD054".to_string()),
688 },
689 LintWarning {
690 message: "Inconsistent link style".to_string(),
691 line: 1,
692 column: 28,
693 end_line: 1,
694 end_column: 0,
695 severity: Severity::Warning,
696 fix: Some(Fix::with_additional_edits(
697 span_b,
698 "[b]".to_string(),
699 vec![Fix::new(
700 content.len()..content.len(),
701 "[b]: https://b.com\n".to_string(),
702 )],
703 )),
704 rule_name: Some("MD054".to_string()),
705 },
706 ];
707
708 let result = apply_warning_fixes(content, &warnings).unwrap();
709
710 assert!(
712 result.contains("See [a] and [b]."),
713 "primary rewrites missing: {result:?}"
714 );
715 assert!(!result.contains("[a](https://a.com)"));
716 assert!(!result.contains("[b](https://b.com)"));
717
718 let pos_a = result.find("[a]: https://a.com").expect("ref-def for [a] missing");
720 let pos_b = result.find("[b]: https://b.com").expect("ref-def for [b] missing");
721 assert!(
722 pos_a < pos_b,
723 "ref-defs must appear in source order ([a] before [b]); got result:\n{result}"
724 );
725 }
726
727 #[test]
728 fn test_warning_fix_to_edit() {
729 let content = "Hello world";
730 let warning = LintWarning {
731 message: "Test".to_string(),
732 line: 1,
733 column: 1,
734 end_line: 1,
735 end_column: 5,
736 severity: Severity::Warning,
737 fix: Some(Fix::new(0..5, "Hi".to_string())),
738 rule_name: Some("TEST".to_string()),
739 };
740
741 let edit = warning_fix_to_edit(content, &warning).unwrap();
742 assert_eq!(edit, (0, 5, "Hi".to_string()));
743 }
744
745 #[test]
746 fn test_warning_fix_to_edit_no_fix() {
747 let content = "Hello world";
748 let warning = LintWarning {
749 message: "Test".to_string(),
750 line: 1,
751 column: 1,
752 end_line: 1,
753 end_column: 5,
754 severity: Severity::Warning,
755 fix: None,
756 rule_name: Some("TEST".to_string()),
757 };
758
759 let result = warning_fix_to_edit(content, &warning);
760 assert!(result.is_err());
761 assert_eq!(result.unwrap_err(), "Warning has no fix");
762 }
763
764 #[test]
765 fn test_warning_fix_to_edit_invalid_range() {
766 let content = "Short";
767 let warning = LintWarning {
768 message: "Test".to_string(),
769 line: 1,
770 column: 1,
771 end_line: 1,
772 end_column: 10,
773 severity: Severity::Warning,
774 fix: Some(Fix::new(0..100, "Long replacement".to_string())),
775 rule_name: Some("TEST".to_string()),
776 };
777
778 let result = warning_fix_to_edit(content, &warning);
779 assert!(result.is_err());
780 assert!(result.unwrap_err().contains("exceeds content length"));
781 }
782
783 #[test]
784 fn test_validate_fix_range() {
785 let content = "Hello world";
786
787 let valid_fix = Fix::new(0..5, "Hi".to_string());
789 assert!(validate_fix_range(content, &valid_fix).is_ok());
790
791 let invalid_fix = Fix::new(0..20, "Hi".to_string());
793 assert!(validate_fix_range(content, &invalid_fix).is_err());
794
795 let start = 5;
797 let end = 3;
798 let invalid_fix2 = Fix::new(start..end, "Hi".to_string());
799 assert!(validate_fix_range(content, &invalid_fix2).is_err());
800 }
801
802 #[test]
803 fn test_validate_fix_range_edge_cases() {
804 let content = "Test";
805
806 let fix1 = Fix::new(0..0, "Insert".to_string());
808 assert!(validate_fix_range(content, &fix1).is_ok());
809
810 let fix2 = Fix::new(4..4, " append".to_string());
812 assert!(validate_fix_range(content, &fix2).is_ok());
813
814 let fix3 = Fix::new(0..4, "Replace".to_string());
816 assert!(validate_fix_range(content, &fix3).is_ok());
817
818 let fix4 = Fix::new(10..11, "Invalid".to_string());
820 let result = validate_fix_range(content, &fix4);
821 assert!(result.is_err());
822 assert!(result.unwrap_err().contains("start 10 exceeds"));
823 }
824
825 #[test]
826 fn test_fix_ordering_stability() {
827 let content = "Test content here";
829 let warnings = vec![
830 LintWarning {
831 message: "First warning".to_string(),
832 line: 1,
833 column: 6,
834 end_line: 1,
835 end_column: 13,
836 severity: Severity::Warning,
837 fix: Some(Fix::new(5..12, "stuff".to_string())),
838 rule_name: Some("MD001".to_string()),
839 },
840 LintWarning {
841 message: "Second warning".to_string(),
842 line: 1,
843 column: 6,
844 end_line: 1,
845 end_column: 13,
846 severity: Severity::Warning,
847 fix: Some(Fix::new(5..12, "stuff".to_string())),
848 rule_name: Some("MD002".to_string()),
849 },
850 ];
851
852 let result = apply_warning_fixes(content, &warnings).unwrap();
854 assert_eq!(result, "Test stuff here");
855 }
856
857 #[test]
858 fn test_line_ending_preservation() {
859 let content_unix = "Line 1\nLine 2\n";
861 let warning = LintWarning {
862 message: "Add text".to_string(),
863 line: 1,
864 column: 7,
865 end_line: 1,
866 end_column: 7,
867 severity: Severity::Warning,
868 fix: Some(Fix::new(6..6, " added".to_string())),
869 rule_name: Some("TEST".to_string()),
870 };
871
872 let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
873 assert_eq!(result, "Line 1 added\nLine 2\n");
874
875 let content_windows = "Line 1\r\nLine 2\r\n";
877 let warning_windows = LintWarning {
878 message: "Add text".to_string(),
879 line: 1,
880 column: 7,
881 end_line: 1,
882 end_column: 7,
883 severity: Severity::Warning,
884 fix: Some(Fix::new(6..6, " added".to_string())),
885 rule_name: Some("TEST".to_string()),
886 };
887
888 let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
889 assert!(result_windows.starts_with("Line 1 added"));
891 assert!(result_windows.contains("Line 2"));
892 }
893
894 fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
895 LintWarning {
896 message: "test".to_string(),
897 line,
898 column: 1,
899 end_line,
900 end_column: 1,
901 severity: Severity::Warning,
902 fix: Some(Fix::new(0..1, "x".to_string())),
903 rule_name: Some(rule_name.to_string()),
904 }
905 }
906
907 #[test]
908 fn test_filter_warnings_disable_enable_block() {
909 let content =
910 "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
911 let inline_config = InlineConfig::from_content(content);
912
913 let warnings = vec![
914 make_warning(4, 4, "MD013"), make_warning(6, 6, "MD013"), ];
917
918 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
919 assert_eq!(filtered.len(), 1);
920 assert_eq!(filtered[0].line, 6);
921 }
922
923 #[test]
924 fn test_filter_warnings_disable_line() {
925 let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
926 let inline_config = InlineConfig::from_content(content);
927
928 let warnings = vec![
929 make_warning(1, 1, "MD009"), make_warning(2, 2, "MD009"), ];
932
933 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
934 assert_eq!(filtered.len(), 1);
935 assert_eq!(filtered[0].line, 2);
936 }
937
938 #[test]
939 fn test_filter_warnings_disable_next_line() {
940 let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
941 let inline_config = InlineConfig::from_content(content);
942
943 let warnings = vec![
944 make_warning(2, 2, "MD034"), make_warning(3, 3, "MD034"), ];
947
948 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
949 assert_eq!(filtered.len(), 1);
950 assert_eq!(filtered[0].line, 3);
951 }
952
953 #[test]
954 fn test_filter_warnings_sub_rule_name() {
955 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
956 let inline_config = InlineConfig::from_content(content);
957
958 let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
960
961 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
962 assert_eq!(filtered.len(), 1);
963 assert_eq!(filtered[0].line, 4);
964 }
965
966 #[test]
967 fn test_filter_warnings_multi_line_warning() {
968 let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
970 let inline_config = InlineConfig::from_content(content);
971
972 let warnings = vec![
973 make_warning(3, 5, "MD013"), make_warning(6, 6, "MD013"), ];
976
977 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
978 assert_eq!(filtered.len(), 1);
980 assert_eq!(filtered[0].line, 6);
981 }
982
983 #[test]
984 fn test_filter_warnings_empty_input() {
985 let inline_config = InlineConfig::from_content("");
986 let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
987 assert!(filtered.is_empty());
988 }
989
990 #[test]
991 fn test_filter_warnings_none_disabled() {
992 let content = "line 1\nline 2\n";
993 let inline_config = InlineConfig::from_content(content);
994
995 let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
996
997 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
998 assert_eq!(filtered.len(), 2);
999 }
1000
1001 #[test]
1002 fn test_filter_warnings_all_disabled() {
1003 let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
1004 let inline_config = InlineConfig::from_content(content);
1005
1006 let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
1007
1008 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1009 assert!(filtered.is_empty());
1010 }
1011
1012 #[test]
1013 fn test_filter_warnings_end_line_zero_fallback() {
1014 let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
1016 let inline_config = InlineConfig::from_content(content);
1017
1018 let warnings = vec![make_warning(1, 0, "MD013")]; let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1021 assert!(filtered.is_empty());
1022 }
1023
1024 #[test]
1025 fn test_filter_non_md_rule_name_preserves_dash() {
1026 let content = "line 1\nline 2\n";
1029 let inline_config = InlineConfig::from_content(content);
1030
1031 let warnings = vec![make_warning(1, 1, "custom-rule")];
1032
1033 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
1035 assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
1036 }
1037
1038 #[test]
1039 fn test_filter_md_sub_rule_name_is_split() {
1040 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
1042 let inline_config = InlineConfig::from_content(content);
1043
1044 let warnings = vec![
1045 make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029"), ];
1048
1049 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
1051 assert_eq!(filtered.len(), 1);
1052 assert_eq!(filtered[0].line, 4);
1053 }
1054
1055 #[test]
1056 fn test_filter_warnings_capture_restore() {
1057 let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
1058 let inline_config = InlineConfig::from_content(content);
1059
1060 let warnings = vec![
1061 make_warning(2, 2, "MD013"), make_warning(5, 5, "MD013"), make_warning(7, 7, "MD013"), ];
1065
1066 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1067 assert_eq!(filtered.len(), 1);
1068 assert_eq!(filtered[0].line, 5);
1069 }
1070}