1use crate::inline_config::InlineConfig;
7use crate::rule::{Fix, LintWarning};
8use std::borrow::Cow;
9use std::ops::Range;
10
11pub fn filter_warnings_by_inline_config(
16 warnings: Vec<LintWarning>,
17 inline_config: &InlineConfig,
18 rule_name: &str,
19) -> Vec<LintWarning> {
20 warnings
21 .into_iter()
22 .filter(|warning| warning_is_enabled(warning, inline_config, rule_name))
23 .collect()
24}
25
26pub(crate) fn warning_is_enabled(warning: &LintWarning, inline_config: &InlineConfig, rule_name: &str) -> bool {
28 let base_rule_name = if let Some(dash_pos) = rule_name.find('-') {
29 let prefix = &rule_name[..dash_pos];
32 if prefix.starts_with("MD") { prefix } else { rule_name }
33 } else {
34 rule_name
35 };
36
37 let end = warning.end_line.max(warning.line);
38 !(warning.line..=end).any(|line| inline_config.is_rule_disabled(base_rule_name, line))
39}
40
41pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
44 apply_edits(content, warnings.iter().filter_map(|warning| warning.fix.as_ref())).map(Cow::into_owned)
45}
46
47pub(crate) fn apply_edits<'a, 'f>(
51 content: &'a str,
52 edits: impl Iterator<Item = &'f Fix>,
53) -> Result<Cow<'a, str>, String> {
54 let mut fixes: Vec<(usize, &Fix)> = edits
55 .enumerate()
56 .flat_map(|(i, fix)| {
57 std::iter::once((i, fix)).chain(fix.additional_edits.iter().map(move |e| (i, e)))
62 })
63 .collect();
64
65 if fixes.is_empty() {
70 return Ok(Cow::Borrowed(content));
71 }
72
73 fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
77 let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
78 if range_cmp != std::cmp::Ordering::Equal {
79 return range_cmp;
80 }
81 let end_cmp = fix_a.range.end.cmp(&fix_b.range.end);
82 if end_cmp != std::cmp::Ordering::Equal {
83 return end_cmp;
84 }
85 idx_a.cmp(idx_b)
86 });
87
88 let mut applicable: Vec<ApplicableEdit<'_>> = Vec::with_capacity(fixes.len());
100 let mut i = 0;
101 while i < fixes.len() {
102 let (_, current) = fixes[i];
103 let mut combined: Option<String> = None;
104 let is_zero_width = current.range.start == current.range.end;
105 let mut j = i + 1;
106 while j < fixes.len() {
107 let (_, next) = fixes[j];
108 if next.range != current.range {
109 break;
110 }
111 if next.replacement == current.replacement {
112 j += 1;
114 continue;
115 }
116 if !is_zero_width {
117 break;
122 }
123 let buf = combined.get_or_insert_with(|| current.replacement.clone());
125 buf.push_str(&next.replacement);
126 j += 1;
127 }
128
129 applicable.push(ApplicableEdit {
130 range: current.range.clone(),
131 replacement: match combined {
132 Some(owned) => Cow::Owned(owned),
133 None => Cow::Borrowed(current.replacement.as_str()),
134 },
135 });
136 i = j;
137 }
138
139 applicable.sort_by(|a, b| {
143 let cmp = b.range.start.cmp(&a.range.start);
144 if cmp != std::cmp::Ordering::Equal {
145 return cmp;
146 }
147 b.range.end.cmp(&a.range.end)
148 });
149
150 let mut kept: Vec<ApplicableEdit<'_>> = Vec::with_capacity(applicable.len());
154
155 let mut min_applied_start = usize::MAX;
160
161 for edit in applicable {
162 if edit.range.end > content.len() {
169 return Err(format!(
170 "Fix range end {} exceeds content length {}",
171 edit.range.end,
172 content.len()
173 ));
174 }
175
176 if edit.range.start > edit.range.end {
177 return Err(format!(
178 "Invalid fix range: start {} > end {}",
179 edit.range.start, edit.range.end
180 ));
181 }
182
183 if !content.is_char_boundary(edit.range.start) || !content.is_char_boundary(edit.range.end) {
187 return Err(format!(
188 "Fix range {}..{} does not lie on UTF-8 char boundaries",
189 edit.range.start, edit.range.end
190 ));
191 }
192
193 if edit.range.end > min_applied_start {
196 continue;
197 }
198
199 min_applied_start = edit.range.start;
200 kept.push(edit);
201 }
202
203 let growth: usize = kept.iter().map(|edit| edit.replacement.len()).sum();
204 let mut result = String::with_capacity(content.len() + growth);
205 let mut copied = 0;
206 for edit in kept.iter().rev() {
207 result.push_str(&content[copied..edit.range.start]);
208 result.push_str(&edit.replacement);
209 copied = edit.range.end;
210 }
211 result.push_str(&content[copied..]);
212
213 let result = super::line_ending::LineEndingPolicy::new(content).apply_owned(result);
215 Ok(Cow::Owned(result))
216}
217
218struct ApplicableEdit<'a> {
222 range: Range<usize>,
223 replacement: Cow<'a, str>,
224}
225
226pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
229 if let Some(fix) = &warning.fix {
230 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 Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
240 } else {
241 Err("Warning has no fix".to_string())
242 }
243}
244
245pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
247 if fix.range.start > content.len() {
248 return Err(format!(
249 "Fix range start {} exceeds content length {}",
250 fix.range.start,
251 content.len()
252 ));
253 }
254
255 if fix.range.end > content.len() {
256 return Err(format!(
257 "Fix range end {} exceeds content length {}",
258 fix.range.end,
259 content.len()
260 ));
261 }
262
263 if fix.range.start > fix.range.end {
264 return Err(format!(
265 "Invalid fix range: start {} > end {}",
266 fix.range.start, fix.range.end
267 ));
268 }
269
270 if !content.is_char_boundary(fix.range.start) || !content.is_char_boundary(fix.range.end) {
273 return Err(format!(
274 "Fix range {}..{} does not lie on UTF-8 char boundaries",
275 fix.range.start, fix.range.end
276 ));
277 }
278
279 Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::rule::{Fix, LintWarning, Severity};
286
287 #[test]
288 fn test_validate_fix_range_rejects_non_char_boundary() {
289 let content = "é world";
291 let fix = Fix::new(1..2, "x".to_string());
292 assert!(
293 validate_fix_range(content, &fix).is_err(),
294 "validate_fix_range must reject a range that splits a codepoint"
295 );
296 let ok_fix = Fix::new(0..2, "x".to_string());
298 assert!(validate_fix_range(content, &ok_fix).is_ok());
299 }
300
301 #[test]
302 fn test_apply_single_fix() {
303 let content = "1. Multiple spaces";
304 let warning = LintWarning {
305 message: "Too many spaces".to_string(),
306 line: 1,
307 column: 3,
308 end_line: 1,
309 end_column: 5,
310 severity: Severity::Warning,
311 fix: Some(Fix::new(2..4, " ".to_string())),
312 rule_name: Some("MD030".to_string()),
313 };
314
315 let result = apply_warning_fixes(content, &[warning]).unwrap();
316 assert_eq!(result, "1. Multiple spaces");
317 }
318
319 #[test]
320 fn test_apply_fix_with_non_char_boundary_range_does_not_panic() {
321 let content = "é world";
325 let warning = LintWarning {
326 message: "bad range".to_string(),
327 line: 1,
328 column: 1,
329 end_line: 1,
330 end_column: 2,
331 severity: Severity::Warning,
332 fix: Some(Fix::new(1..2, "x".to_string())),
333 rule_name: Some("MDTEST".to_string()),
334 };
335
336 let result = apply_warning_fixes(content, &[warning]);
337 assert!(
338 result.is_err(),
339 "non-char-boundary range must be rejected, got {result:?}"
340 );
341 }
342
343 #[test]
344 fn test_apply_multiple_fixes() {
345 let content = "1. First\n* Second";
346 let warnings = vec![
347 LintWarning {
348 message: "Too many spaces".to_string(),
349 line: 1,
350 column: 3,
351 end_line: 1,
352 end_column: 5,
353 severity: Severity::Warning,
354 fix: Some(Fix::new(2..4, " ".to_string())),
355 rule_name: Some("MD030".to_string()),
356 },
357 LintWarning {
358 message: "Too many spaces".to_string(),
359 line: 2,
360 column: 2,
361 end_line: 2,
362 end_column: 5,
363 severity: Severity::Warning,
364 fix: Some(Fix::new(11..14, " ".to_string())),
365 rule_name: Some("MD030".to_string()),
366 },
367 ];
368
369 let result = apply_warning_fixes(content, &warnings).unwrap();
370 assert_eq!(result, "1. First\n* Second");
371 }
372
373 #[test]
374 fn test_apply_non_overlapping_fixes() {
375 let content = "Test multiple spaces";
380 let warnings = vec![
381 LintWarning {
382 message: "Too many spaces".to_string(),
383 line: 1,
384 column: 5,
385 end_line: 1,
386 end_column: 7,
387 severity: Severity::Warning,
388 fix: Some(Fix::new(4..6, " ".to_string())),
389 rule_name: Some("MD009".to_string()),
390 },
391 LintWarning {
392 message: "Too many spaces".to_string(),
393 line: 1,
394 column: 15,
395 end_line: 1,
396 end_column: 19,
397 severity: Severity::Warning,
398 fix: Some(Fix::new(14..18, " ".to_string())),
399 rule_name: Some("MD009".to_string()),
400 },
401 ];
402
403 let result = apply_warning_fixes(content, &warnings).unwrap();
404 assert_eq!(result, "Test multiple spaces");
405 }
406
407 #[test]
408 fn test_apply_duplicate_fixes() {
409 let content = "Test content";
410 let warnings = vec![
411 LintWarning {
412 message: "Fix 1".to_string(),
413 line: 1,
414 column: 5,
415 end_line: 1,
416 end_column: 7,
417 severity: Severity::Warning,
418 fix: Some(Fix::new(4..6, " ".to_string())),
419 rule_name: Some("MD009".to_string()),
420 },
421 LintWarning {
422 message: "Fix 2 (duplicate)".to_string(),
423 line: 1,
424 column: 5,
425 end_line: 1,
426 end_column: 7,
427 severity: Severity::Warning,
428 fix: Some(Fix::new(4..6, " ".to_string())),
429 rule_name: Some("MD009".to_string()),
430 },
431 ];
432
433 let result = apply_warning_fixes(content, &warnings).unwrap();
435 assert_eq!(result, "Test content");
436 }
437
438 #[test]
439 fn test_apply_fixes_with_windows_line_endings() {
440 let content = "1. First\r\n* Second\r\n";
441 let warnings = vec![
442 LintWarning {
443 message: "Too many spaces".to_string(),
444 line: 1,
445 column: 3,
446 end_line: 1,
447 end_column: 5,
448 severity: Severity::Warning,
449 fix: Some(Fix::new(2..4, " ".to_string())),
450 rule_name: Some("MD030".to_string()),
451 },
452 LintWarning {
453 message: "Too many spaces".to_string(),
454 line: 2,
455 column: 2,
456 end_line: 2,
457 end_column: 5,
458 severity: Severity::Warning,
459 fix: Some(Fix::new(12..15, " ".to_string())),
460 rule_name: Some("MD030".to_string()),
461 },
462 ];
463
464 let result = apply_warning_fixes(content, &warnings).unwrap();
465 assert!(result.contains("1. First"));
468 assert!(result.contains("* Second"));
469 }
470
471 #[test]
472 fn test_apply_fix_with_invalid_range() {
473 let content = "Short";
474 let warning = LintWarning {
475 message: "Invalid fix".to_string(),
476 line: 1,
477 column: 1,
478 end_line: 1,
479 end_column: 10,
480 severity: Severity::Warning,
481 fix: Some(Fix::new(0..100, "Replacement".to_string())),
482 rule_name: Some("TEST".to_string()),
483 };
484
485 let result = apply_warning_fixes(content, &[warning]);
486 assert!(result.is_err());
487 assert!(result.unwrap_err().contains("exceeds content length"));
488 }
489
490 #[test]
491 #[allow(clippy::reversed_empty_ranges)]
492 fn test_apply_fix_with_reversed_range() {
493 let content = "Hello world";
494 let warning = LintWarning {
495 message: "Invalid fix".to_string(),
496 line: 1,
497 column: 5,
498 end_line: 1,
499 end_column: 3,
500 severity: Severity::Warning,
501 fix: Some(Fix::new(10..5, "Test".to_string())),
502 rule_name: Some("TEST".to_string()),
503 };
504
505 let result = apply_warning_fixes(content, &[warning]);
506 assert!(result.is_err());
507 assert!(result.unwrap_err().contains("Invalid fix range"));
508 }
509
510 #[test]
511 fn test_apply_no_fixes() {
512 let content = "No changes needed";
513 let warnings = vec![LintWarning {
514 message: "Warning without fix".to_string(),
515 line: 1,
516 column: 1,
517 end_line: 1,
518 end_column: 5,
519 severity: Severity::Warning,
520 fix: None,
521 rule_name: Some("TEST".to_string()),
522 }];
523
524 let result = apply_warning_fixes(content, &warnings).unwrap();
525 assert_eq!(result, content);
526 }
527
528 #[test]
529 fn test_overlapping_fixes_skip_outer() {
530 let content = "[  ](url) suffix";
535 let warnings = vec](url)".to_string())),
544 rule_name: Some("MD039".to_string()),
545 },
546 LintWarning {
547 message: "Inner image".to_string(),
548 line: 1,
549 column: 3,
550 end_line: 1,
551 end_column: 15,
552 severity: Severity::Warning,
553 fix: Some(Fix::new(2..15, "".to_string())),
554 rule_name: Some("MD039".to_string()),
555 },
556 ];
557
558 let result = apply_warning_fixes(content, &warnings).unwrap();
559 assert_eq!(result, "[  ](url) suffix");
562 }
563
564 #[test]
565 fn test_overlapping_outer_fix_is_skipped_without_trailing_slack() {
566 let content = "[ a  b ](t.md)\n";
573 let warnings = vec b](t.md)".to_string())),
582 rule_name: Some("MD039".to_string()),
583 },
584 LintWarning {
585 message: "Inner image".to_string(),
586 line: 1,
587 column: 5,
588 end_line: 1,
589 end_column: 18,
590 severity: Severity::Warning,
591 fix: Some(Fix::new(4..17, "".to_string())),
592 rule_name: Some("MD039".to_string()),
593 },
594 ];
595
596 let result = apply_warning_fixes(content, &warnings).unwrap();
597 assert_eq!(result, "[ a  b ](t.md)\n");
598 }
599
600 #[test]
601 fn test_out_of_bounds_fix_is_still_rejected() {
602 let content = "short\n";
606 let warnings = vec![LintWarning {
607 message: "Past the end".to_string(),
608 line: 1,
609 column: 1,
610 end_line: 1,
611 end_column: 1,
612 severity: Severity::Warning,
613 fix: Some(Fix::new(0..99, "x".to_string())),
614 rule_name: Some("MDTEST".to_string()),
615 }];
616
617 let err = apply_warning_fixes(content, &warnings).expect_err("out-of-bounds range must be rejected");
621 assert_eq!(err, "Fix range end 99 exceeds content length 6");
622 }
623
624 #[test]
625 fn test_apply_fix_with_additional_edits_atomic() {
626 let content = "See [docs](https://example.com) for details.\n";
631 let primary_range = content.find("[docs](https://example.com)").unwrap()..content.find(" for details").unwrap();
632 let appended = "\n[docs]: https://example.com\n".to_string();
633 let warnings = vec![LintWarning {
634 message: "Inconsistent link style".to_string(),
635 line: 1,
636 column: 5,
637 end_line: 1,
638 end_column: 32,
639 severity: Severity::Warning,
640 fix: Some(Fix::with_additional_edits(
641 primary_range,
642 "[docs]".to_string(),
643 vec![Fix::new(content.len()..content.len(), appended)],
644 )),
645 rule_name: Some("MD054".to_string()),
646 }];
647
648 let result = apply_warning_fixes(content, &warnings).unwrap();
649 assert!(
650 result.contains("See [docs] for details."),
651 "primary edit must rewrite the inline link in place: {result:?}"
652 );
653 assert!(
654 result.contains("[docs]: https://example.com"),
655 "additional edit must append the ref-def at EOF: {result:?}"
656 );
657 assert!(
658 !result.contains("[docs](https://example.com)"),
659 "the inline form must be gone after the atomic fix: {result:?}"
660 );
661 }
662
663 #[test]
664 fn test_apply_two_ref_emit_fixes_preserve_source_order() {
665 let content = "See [a](https://a.com) and [b](https://b.com).\n";
678 let span_a = content.find("[a](https://a.com)").unwrap()
679 ..content.find("](https://a.com)").unwrap() + "](https://a.com)".len();
680 let span_b = content.find("[b](https://b.com)").unwrap()
681 ..content.find("](https://b.com)").unwrap() + "](https://b.com)".len();
682 let warnings = vec![
683 LintWarning {
684 message: "Inconsistent link style".to_string(),
685 line: 1,
686 column: 5,
687 end_line: 1,
688 end_column: 0,
689 severity: Severity::Warning,
690 fix: Some(Fix::with_additional_edits(
691 span_a,
692 "[a]".to_string(),
693 vec![Fix::new(
694 content.len()..content.len(),
695 "[a]: https://a.com\n".to_string(),
696 )],
697 )),
698 rule_name: Some("MD054".to_string()),
699 },
700 LintWarning {
701 message: "Inconsistent link style".to_string(),
702 line: 1,
703 column: 28,
704 end_line: 1,
705 end_column: 0,
706 severity: Severity::Warning,
707 fix: Some(Fix::with_additional_edits(
708 span_b,
709 "[b]".to_string(),
710 vec![Fix::new(
711 content.len()..content.len(),
712 "[b]: https://b.com\n".to_string(),
713 )],
714 )),
715 rule_name: Some("MD054".to_string()),
716 },
717 ];
718
719 let result = apply_warning_fixes(content, &warnings).unwrap();
720
721 assert!(
723 result.contains("See [a] and [b]."),
724 "primary rewrites missing: {result:?}"
725 );
726 assert!(!result.contains("[a](https://a.com)"));
727 assert!(!result.contains("[b](https://b.com)"));
728
729 let pos_a = result.find("[a]: https://a.com").expect("ref-def for [a] missing");
731 let pos_b = result.find("[b]: https://b.com").expect("ref-def for [b] missing");
732 assert!(
733 pos_a < pos_b,
734 "ref-defs must appear in source order ([a] before [b]); got result:\n{result}"
735 );
736 }
737
738 #[test]
739 fn test_warning_fix_to_edit() {
740 let content = "Hello world";
741 let warning = LintWarning {
742 message: "Test".to_string(),
743 line: 1,
744 column: 1,
745 end_line: 1,
746 end_column: 5,
747 severity: Severity::Warning,
748 fix: Some(Fix::new(0..5, "Hi".to_string())),
749 rule_name: Some("TEST".to_string()),
750 };
751
752 let edit = warning_fix_to_edit(content, &warning).unwrap();
753 assert_eq!(edit, (0, 5, "Hi".to_string()));
754 }
755
756 #[test]
757 fn test_warning_fix_to_edit_no_fix() {
758 let content = "Hello world";
759 let warning = LintWarning {
760 message: "Test".to_string(),
761 line: 1,
762 column: 1,
763 end_line: 1,
764 end_column: 5,
765 severity: Severity::Warning,
766 fix: None,
767 rule_name: Some("TEST".to_string()),
768 };
769
770 let result = warning_fix_to_edit(content, &warning);
771 assert!(result.is_err());
772 assert_eq!(result.unwrap_err(), "Warning has no fix");
773 }
774
775 #[test]
776 fn test_warning_fix_to_edit_invalid_range() {
777 let content = "Short";
778 let warning = LintWarning {
779 message: "Test".to_string(),
780 line: 1,
781 column: 1,
782 end_line: 1,
783 end_column: 10,
784 severity: Severity::Warning,
785 fix: Some(Fix::new(0..100, "Long replacement".to_string())),
786 rule_name: Some("TEST".to_string()),
787 };
788
789 let result = warning_fix_to_edit(content, &warning);
790 assert!(result.is_err());
791 assert!(result.unwrap_err().contains("exceeds content length"));
792 }
793
794 #[test]
795 fn test_validate_fix_range() {
796 let content = "Hello world";
797
798 let valid_fix = Fix::new(0..5, "Hi".to_string());
800 assert!(validate_fix_range(content, &valid_fix).is_ok());
801
802 let invalid_fix = Fix::new(0..20, "Hi".to_string());
804 assert!(validate_fix_range(content, &invalid_fix).is_err());
805
806 let start = 5;
808 let end = 3;
809 let invalid_fix2 = Fix::new(start..end, "Hi".to_string());
810 assert!(validate_fix_range(content, &invalid_fix2).is_err());
811 }
812
813 #[test]
814 fn test_validate_fix_range_edge_cases() {
815 let content = "Test";
816
817 let fix1 = Fix::new(0..0, "Insert".to_string());
819 assert!(validate_fix_range(content, &fix1).is_ok());
820
821 let fix2 = Fix::new(4..4, " append".to_string());
823 assert!(validate_fix_range(content, &fix2).is_ok());
824
825 let fix3 = Fix::new(0..4, "Replace".to_string());
827 assert!(validate_fix_range(content, &fix3).is_ok());
828
829 let fix4 = Fix::new(10..11, "Invalid".to_string());
831 let result = validate_fix_range(content, &fix4);
832 assert!(result.is_err());
833 assert!(result.unwrap_err().contains("start 10 exceeds"));
834 }
835
836 #[test]
837 fn test_fix_ordering_stability() {
838 let content = "Test content here";
840 let warnings = vec![
841 LintWarning {
842 message: "First warning".to_string(),
843 line: 1,
844 column: 6,
845 end_line: 1,
846 end_column: 13,
847 severity: Severity::Warning,
848 fix: Some(Fix::new(5..12, "stuff".to_string())),
849 rule_name: Some("MD001".to_string()),
850 },
851 LintWarning {
852 message: "Second warning".to_string(),
853 line: 1,
854 column: 6,
855 end_line: 1,
856 end_column: 13,
857 severity: Severity::Warning,
858 fix: Some(Fix::new(5..12, "stuff".to_string())),
859 rule_name: Some("MD002".to_string()),
860 },
861 ];
862
863 let result = apply_warning_fixes(content, &warnings).unwrap();
865 assert_eq!(result, "Test stuff here");
866 }
867
868 #[test]
869 fn test_line_ending_preservation() {
870 let content_unix = "Line 1\nLine 2\n";
872 let warning = LintWarning {
873 message: "Add text".to_string(),
874 line: 1,
875 column: 7,
876 end_line: 1,
877 end_column: 7,
878 severity: Severity::Warning,
879 fix: Some(Fix::new(6..6, " added".to_string())),
880 rule_name: Some("TEST".to_string()),
881 };
882
883 let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
884 assert_eq!(result, "Line 1 added\nLine 2\n");
885
886 let content_windows = "Line 1\r\nLine 2\r\n";
888 let warning_windows = LintWarning {
889 message: "Add text".to_string(),
890 line: 1,
891 column: 7,
892 end_line: 1,
893 end_column: 7,
894 severity: Severity::Warning,
895 fix: Some(Fix::new(6..6, " added".to_string())),
896 rule_name: Some("TEST".to_string()),
897 };
898
899 let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
900 assert!(result_windows.starts_with("Line 1 added"));
902 assert!(result_windows.contains("Line 2"));
903 }
904
905 fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
906 LintWarning {
907 message: "test".to_string(),
908 line,
909 column: 1,
910 end_line,
911 end_column: 1,
912 severity: Severity::Warning,
913 fix: Some(Fix::new(0..1, "x".to_string())),
914 rule_name: Some(rule_name.to_string()),
915 }
916 }
917
918 #[test]
919 fn test_filter_warnings_disable_enable_block() {
920 let content =
921 "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
922 let inline_config = InlineConfig::from_content(content);
923
924 let warnings = vec![
925 make_warning(4, 4, "MD013"), make_warning(6, 6, "MD013"), ];
928
929 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
930 assert_eq!(filtered.len(), 1);
931 assert_eq!(filtered[0].line, 6);
932 }
933
934 #[test]
935 fn test_filter_warnings_disable_line() {
936 let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
937 let inline_config = InlineConfig::from_content(content);
938
939 let warnings = vec![
940 make_warning(1, 1, "MD009"), make_warning(2, 2, "MD009"), ];
943
944 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
945 assert_eq!(filtered.len(), 1);
946 assert_eq!(filtered[0].line, 2);
947 }
948
949 #[test]
950 fn test_filter_warnings_disable_next_line() {
951 let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
952 let inline_config = InlineConfig::from_content(content);
953
954 let warnings = vec![
955 make_warning(2, 2, "MD034"), make_warning(3, 3, "MD034"), ];
958
959 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
960 assert_eq!(filtered.len(), 1);
961 assert_eq!(filtered[0].line, 3);
962 }
963
964 #[test]
965 fn test_filter_warnings_sub_rule_name() {
966 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
967 let inline_config = InlineConfig::from_content(content);
968
969 let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
971
972 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
973 assert_eq!(filtered.len(), 1);
974 assert_eq!(filtered[0].line, 4);
975 }
976
977 #[test]
978 fn test_filter_warnings_multi_line_warning() {
979 let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
981 let inline_config = InlineConfig::from_content(content);
982
983 let warnings = vec![
984 make_warning(3, 5, "MD013"), make_warning(6, 6, "MD013"), ];
987
988 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
989 assert_eq!(filtered.len(), 1);
991 assert_eq!(filtered[0].line, 6);
992 }
993
994 #[test]
995 fn test_filter_warnings_empty_input() {
996 let inline_config = InlineConfig::from_content("");
997 let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
998 assert!(filtered.is_empty());
999 }
1000
1001 #[test]
1002 fn test_filter_warnings_none_disabled() {
1003 let content = "line 1\nline 2\n";
1004 let inline_config = InlineConfig::from_content(content);
1005
1006 let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
1007
1008 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1009 assert_eq!(filtered.len(), 2);
1010 }
1011
1012 #[test]
1013 fn test_filter_warnings_all_disabled() {
1014 let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
1015 let inline_config = InlineConfig::from_content(content);
1016
1017 let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
1018
1019 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1020 assert!(filtered.is_empty());
1021 }
1022
1023 #[test]
1024 fn test_filter_warnings_end_line_zero_fallback() {
1025 let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
1027 let inline_config = InlineConfig::from_content(content);
1028
1029 let warnings = vec![make_warning(1, 0, "MD013")]; let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1032 assert!(filtered.is_empty());
1033 }
1034
1035 #[test]
1036 fn test_filter_non_md_rule_name_preserves_dash() {
1037 let content = "line 1\nline 2\n";
1040 let inline_config = InlineConfig::from_content(content);
1041
1042 let warnings = vec![make_warning(1, 1, "custom-rule")];
1043
1044 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
1046 assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
1047 }
1048
1049 #[test]
1050 fn test_filter_md_sub_rule_name_is_split() {
1051 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
1053 let inline_config = InlineConfig::from_content(content);
1054
1055 let warnings = vec![
1056 make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029"), ];
1059
1060 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
1062 assert_eq!(filtered.len(), 1);
1063 assert_eq!(filtered[0].line, 4);
1064 }
1065
1066 #[test]
1067 fn test_filter_warnings_capture_restore() {
1068 let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
1069 let inline_config = InlineConfig::from_content(content);
1070
1071 let warnings = vec![
1072 make_warning(2, 2, "MD013"), make_warning(5, 5, "MD013"), make_warning(7, 7, "MD013"), ];
1076
1077 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1078 assert_eq!(filtered.len(), 1);
1079 assert_eq!(filtered[0].line, 5);
1080 }
1081}