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 !warning
38 .covered_lines()
39 .any(|line| inline_config.is_rule_disabled(base_rule_name, line))
40}
41
42pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
45 apply_edits(content, warnings.iter().filter_map(|warning| warning.fix.as_ref())).map(Cow::into_owned)
46}
47
48fn blank_line_insertion(text: &str) -> Option<(&str, usize)> {
50 let mut lines = text.strip_suffix('\n')?.split('\n');
51 let prefix = lines.next()?;
52 if !prefix.bytes().all(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'>')) {
53 return None;
54 }
55 let mut count = 1;
56 for line in lines {
57 if line != prefix {
58 return None;
59 }
60 count += 1;
61 }
62 Some((prefix, count))
63}
64
65pub(crate) fn apply_edits<'a, 'f>(
69 content: &'a str,
70 edits: impl Iterator<Item = &'f Fix>,
71) -> Result<Cow<'a, str>, String> {
72 let mut fixes: Vec<(usize, &Fix)> = edits
73 .enumerate()
74 .flat_map(|(i, fix)| {
75 std::iter::once((i, fix)).chain(fix.additional_edits.iter().map(move |e| (i, e)))
80 })
81 .collect();
82
83 if fixes.is_empty() {
88 return Ok(Cow::Borrowed(content));
89 }
90
91 fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
95 let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
96 if range_cmp != std::cmp::Ordering::Equal {
97 return range_cmp;
98 }
99 let end_cmp = fix_a.range.end.cmp(&fix_b.range.end);
100 if end_cmp != std::cmp::Ordering::Equal {
101 return end_cmp;
102 }
103 idx_a.cmp(idx_b)
104 });
105
106 let mut applicable: Vec<ApplicableEdit<'_>> = Vec::with_capacity(fixes.len());
118 let mut i = 0;
119 while i < fixes.len() {
120 let (_, current) = fixes[i];
121 let mut combined: Option<String> = None;
122 let is_zero_width = current.range.start == current.range.end;
123 let mut j = i + 1;
124 while j < fixes.len() {
125 let (_, next) = fixes[j];
126 if next.range != current.range {
127 break;
128 }
129 if next.replacement == current.replacement {
130 j += 1;
132 continue;
133 }
134 if !is_zero_width {
135 break;
140 }
141 if current.range.start == 0 || content.as_bytes().get(current.range.start - 1) == Some(&b'\n') {
145 let existing = combined.as_deref().unwrap_or(¤t.replacement);
146 if let (Some((prefix, count)), Some((next_prefix, next_count))) =
147 (blank_line_insertion(existing), blank_line_insertion(&next.replacement))
148 && prefix == next_prefix
149 {
150 if next_count > count {
151 combined = Some(next.replacement.clone());
152 }
153 j += 1;
154 continue;
155 }
156 }
157 let buf = combined.get_or_insert_with(|| current.replacement.clone());
159 buf.push_str(&next.replacement);
160 j += 1;
161 }
162
163 applicable.push(ApplicableEdit {
164 range: current.range.clone(),
165 replacement: match combined {
166 Some(owned) => Cow::Owned(owned),
167 None => Cow::Borrowed(current.replacement.as_str()),
168 },
169 });
170 i = j;
171 }
172
173 applicable.sort_by(|a, b| {
177 let cmp = b.range.start.cmp(&a.range.start);
178 if cmp != std::cmp::Ordering::Equal {
179 return cmp;
180 }
181 b.range.end.cmp(&a.range.end)
182 });
183
184 let mut kept: Vec<ApplicableEdit<'_>> = Vec::with_capacity(applicable.len());
188
189 let mut min_applied_start = usize::MAX;
194
195 for edit in applicable {
196 if edit.range.end > content.len() {
203 return Err(format!(
204 "Fix range end {} exceeds content length {}",
205 edit.range.end,
206 content.len()
207 ));
208 }
209
210 if edit.range.start > edit.range.end {
211 return Err(format!(
212 "Invalid fix range: start {} > end {}",
213 edit.range.start, edit.range.end
214 ));
215 }
216
217 if !content.is_char_boundary(edit.range.start) || !content.is_char_boundary(edit.range.end) {
221 return Err(format!(
222 "Fix range {}..{} does not lie on UTF-8 char boundaries",
223 edit.range.start, edit.range.end
224 ));
225 }
226
227 if edit.range.end > min_applied_start {
230 continue;
231 }
232
233 min_applied_start = edit.range.start;
234 kept.push(edit);
235 }
236
237 let growth: usize = kept.iter().map(|edit| edit.replacement.len()).sum();
238 let mut result = String::with_capacity(content.len() + growth);
239 let mut copied = 0;
240 for edit in kept.iter().rev() {
241 result.push_str(&content[copied..edit.range.start]);
242 result.push_str(&edit.replacement);
243 copied = edit.range.end;
244 }
245 result.push_str(&content[copied..]);
246
247 let result = super::line_ending::LineEndingPolicy::new(content).apply_owned(result);
249 Ok(Cow::Owned(result))
250}
251
252struct ApplicableEdit<'a> {
256 range: Range<usize>,
257 replacement: Cow<'a, str>,
258}
259
260pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
263 if let Some(fix) = &warning.fix {
264 if fix.range.end > content.len() {
266 return Err(format!(
267 "Fix range end {} exceeds content length {}",
268 fix.range.end,
269 content.len()
270 ));
271 }
272
273 Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
274 } else {
275 Err("Warning has no fix".to_string())
276 }
277}
278
279pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
281 if fix.range.start > content.len() {
282 return Err(format!(
283 "Fix range start {} exceeds content length {}",
284 fix.range.start,
285 content.len()
286 ));
287 }
288
289 if fix.range.end > content.len() {
290 return Err(format!(
291 "Fix range end {} exceeds content length {}",
292 fix.range.end,
293 content.len()
294 ));
295 }
296
297 if fix.range.start > fix.range.end {
298 return Err(format!(
299 "Invalid fix range: start {} > end {}",
300 fix.range.start, fix.range.end
301 ));
302 }
303
304 if !content.is_char_boundary(fix.range.start) || !content.is_char_boundary(fix.range.end) {
307 return Err(format!(
308 "Fix range {}..{} does not lie on UTF-8 char boundaries",
309 fix.range.start, fix.range.end
310 ));
311 }
312
313 Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::rule::{Fix, LintWarning, Severity};
320
321 #[test]
322 fn test_validate_fix_range_rejects_non_char_boundary() {
323 let content = "é world";
325 let fix = Fix::new(1..2, "x".to_string());
326 assert!(
327 validate_fix_range(content, &fix).is_err(),
328 "validate_fix_range must reject a range that splits a codepoint"
329 );
330 let ok_fix = Fix::new(0..2, "x".to_string());
332 assert!(validate_fix_range(content, &ok_fix).is_ok());
333 }
334
335 #[test]
336 fn test_apply_single_fix() {
337 let content = "1. Multiple spaces";
338 let warning = LintWarning {
339 message: "Too many spaces".to_string(),
340 line: 1,
341 column: 3,
342 end_line: 1,
343 end_column: 5,
344 severity: Severity::Warning,
345 fix: Some(Fix::new(2..4, " ".to_string())),
346 rule_name: Some("MD030".to_string()),
347 };
348
349 let result = apply_warning_fixes(content, &[warning]).unwrap();
350 assert_eq!(result, "1. Multiple spaces");
351 }
352
353 #[test]
354 fn test_apply_fix_with_non_char_boundary_range_does_not_panic() {
355 let content = "é world";
359 let warning = LintWarning {
360 message: "bad range".to_string(),
361 line: 1,
362 column: 1,
363 end_line: 1,
364 end_column: 2,
365 severity: Severity::Warning,
366 fix: Some(Fix::new(1..2, "x".to_string())),
367 rule_name: Some("MDTEST".to_string()),
368 };
369
370 let result = apply_warning_fixes(content, &[warning]);
371 assert!(
372 result.is_err(),
373 "non-char-boundary range must be rejected, got {result:?}"
374 );
375 }
376
377 #[test]
378 fn test_apply_multiple_fixes() {
379 let content = "1. First\n* Second";
380 let warnings = vec![
381 LintWarning {
382 message: "Too many spaces".to_string(),
383 line: 1,
384 column: 3,
385 end_line: 1,
386 end_column: 5,
387 severity: Severity::Warning,
388 fix: Some(Fix::new(2..4, " ".to_string())),
389 rule_name: Some("MD030".to_string()),
390 },
391 LintWarning {
392 message: "Too many spaces".to_string(),
393 line: 2,
394 column: 2,
395 end_line: 2,
396 end_column: 5,
397 severity: Severity::Warning,
398 fix: Some(Fix::new(11..14, " ".to_string())),
399 rule_name: Some("MD030".to_string()),
400 },
401 ];
402
403 let result = apply_warning_fixes(content, &warnings).unwrap();
404 assert_eq!(result, "1. First\n* Second");
405 }
406
407 #[test]
408 fn test_apply_non_overlapping_fixes() {
409 let content = "Test multiple spaces";
414 let warnings = vec![
415 LintWarning {
416 message: "Too many spaces".to_string(),
417 line: 1,
418 column: 5,
419 end_line: 1,
420 end_column: 7,
421 severity: Severity::Warning,
422 fix: Some(Fix::new(4..6, " ".to_string())),
423 rule_name: Some("MD009".to_string()),
424 },
425 LintWarning {
426 message: "Too many spaces".to_string(),
427 line: 1,
428 column: 15,
429 end_line: 1,
430 end_column: 19,
431 severity: Severity::Warning,
432 fix: Some(Fix::new(14..18, " ".to_string())),
433 rule_name: Some("MD009".to_string()),
434 },
435 ];
436
437 let result = apply_warning_fixes(content, &warnings).unwrap();
438 assert_eq!(result, "Test multiple spaces");
439 }
440
441 #[test]
442 fn test_apply_duplicate_fixes() {
443 let content = "Test content";
444 let warnings = vec![
445 LintWarning {
446 message: "Fix 1".to_string(),
447 line: 1,
448 column: 5,
449 end_line: 1,
450 end_column: 7,
451 severity: Severity::Warning,
452 fix: Some(Fix::new(4..6, " ".to_string())),
453 rule_name: Some("MD009".to_string()),
454 },
455 LintWarning {
456 message: "Fix 2 (duplicate)".to_string(),
457 line: 1,
458 column: 5,
459 end_line: 1,
460 end_column: 7,
461 severity: Severity::Warning,
462 fix: Some(Fix::new(4..6, " ".to_string())),
463 rule_name: Some("MD009".to_string()),
464 },
465 ];
466
467 let result = apply_warning_fixes(content, &warnings).unwrap();
469 assert_eq!(result, "Test content");
470 }
471
472 #[test]
473 fn test_apply_fixes_with_windows_line_endings() {
474 let content = "1. First\r\n* Second\r\n";
475 let warnings = vec![
476 LintWarning {
477 message: "Too many spaces".to_string(),
478 line: 1,
479 column: 3,
480 end_line: 1,
481 end_column: 5,
482 severity: Severity::Warning,
483 fix: Some(Fix::new(2..4, " ".to_string())),
484 rule_name: Some("MD030".to_string()),
485 },
486 LintWarning {
487 message: "Too many spaces".to_string(),
488 line: 2,
489 column: 2,
490 end_line: 2,
491 end_column: 5,
492 severity: Severity::Warning,
493 fix: Some(Fix::new(12..15, " ".to_string())),
494 rule_name: Some("MD030".to_string()),
495 },
496 ];
497
498 let result = apply_warning_fixes(content, &warnings).unwrap();
499 assert!(result.contains("1. First"));
502 assert!(result.contains("* Second"));
503 }
504
505 #[test]
506 fn test_apply_fix_with_invalid_range() {
507 let content = "Short";
508 let warning = LintWarning {
509 message: "Invalid fix".to_string(),
510 line: 1,
511 column: 1,
512 end_line: 1,
513 end_column: 10,
514 severity: Severity::Warning,
515 fix: Some(Fix::new(0..100, "Replacement".to_string())),
516 rule_name: Some("TEST".to_string()),
517 };
518
519 let result = apply_warning_fixes(content, &[warning]);
520 assert!(result.is_err());
521 assert!(result.unwrap_err().contains("exceeds content length"));
522 }
523
524 #[test]
525 #[allow(clippy::reversed_empty_ranges)]
526 fn test_apply_fix_with_reversed_range() {
527 let content = "Hello world";
528 let warning = LintWarning {
529 message: "Invalid fix".to_string(),
530 line: 1,
531 column: 5,
532 end_line: 1,
533 end_column: 3,
534 severity: Severity::Warning,
535 fix: Some(Fix::new(10..5, "Test".to_string())),
536 rule_name: Some("TEST".to_string()),
537 };
538
539 let result = apply_warning_fixes(content, &[warning]);
540 assert!(result.is_err());
541 assert!(result.unwrap_err().contains("Invalid fix range"));
542 }
543
544 #[test]
545 fn test_apply_no_fixes() {
546 let content = "No changes needed";
547 let warnings = vec![LintWarning {
548 message: "Warning without fix".to_string(),
549 line: 1,
550 column: 1,
551 end_line: 1,
552 end_column: 5,
553 severity: Severity::Warning,
554 fix: None,
555 rule_name: Some("TEST".to_string()),
556 }];
557
558 let result = apply_warning_fixes(content, &warnings).unwrap();
559 assert_eq!(result, content);
560 }
561
562 #[test]
563 fn test_overlapping_fixes_skip_outer() {
564 let content = "[  ](url) suffix";
569 let warnings = vec](url)".to_string())),
578 rule_name: Some("MD039".to_string()),
579 },
580 LintWarning {
581 message: "Inner image".to_string(),
582 line: 1,
583 column: 3,
584 end_line: 1,
585 end_column: 15,
586 severity: Severity::Warning,
587 fix: Some(Fix::new(2..15, "".to_string())),
588 rule_name: Some("MD039".to_string()),
589 },
590 ];
591
592 let result = apply_warning_fixes(content, &warnings).unwrap();
593 assert_eq!(result, "[  ](url) suffix");
596 }
597
598 #[test]
599 fn test_overlapping_outer_fix_is_skipped_without_trailing_slack() {
600 let content = "[ a  b ](t.md)\n";
607 let warnings = vec b](t.md)".to_string())),
616 rule_name: Some("MD039".to_string()),
617 },
618 LintWarning {
619 message: "Inner image".to_string(),
620 line: 1,
621 column: 5,
622 end_line: 1,
623 end_column: 18,
624 severity: Severity::Warning,
625 fix: Some(Fix::new(4..17, "".to_string())),
626 rule_name: Some("MD039".to_string()),
627 },
628 ];
629
630 let result = apply_warning_fixes(content, &warnings).unwrap();
631 assert_eq!(result, "[ a  b ](t.md)\n");
632 }
633
634 #[test]
635 fn test_out_of_bounds_fix_is_still_rejected() {
636 let content = "short\n";
640 let warnings = vec![LintWarning {
641 message: "Past the end".to_string(),
642 line: 1,
643 column: 1,
644 end_line: 1,
645 end_column: 1,
646 severity: Severity::Warning,
647 fix: Some(Fix::new(0..99, "x".to_string())),
648 rule_name: Some("MDTEST".to_string()),
649 }];
650
651 let err = apply_warning_fixes(content, &warnings).expect_err("out-of-bounds range must be rejected");
655 assert_eq!(err, "Fix range end 99 exceeds content length 6");
656 }
657
658 #[test]
659 fn test_apply_fix_with_additional_edits_atomic() {
660 let content = "See [docs](https://example.com) for details.\n";
665 let primary_range = content.find("[docs](https://example.com)").unwrap()..content.find(" for details").unwrap();
666 let appended = "\n[docs]: https://example.com\n".to_string();
667 let warnings = vec![LintWarning {
668 message: "Inconsistent link style".to_string(),
669 line: 1,
670 column: 5,
671 end_line: 1,
672 end_column: 32,
673 severity: Severity::Warning,
674 fix: Some(Fix::with_additional_edits(
675 primary_range,
676 "[docs]".to_string(),
677 vec![Fix::new(content.len()..content.len(), appended)],
678 )),
679 rule_name: Some("MD054".to_string()),
680 }];
681
682 let result = apply_warning_fixes(content, &warnings).unwrap();
683 assert!(
684 result.contains("See [docs] for details."),
685 "primary edit must rewrite the inline link in place: {result:?}"
686 );
687 assert!(
688 result.contains("[docs]: https://example.com"),
689 "additional edit must append the ref-def at EOF: {result:?}"
690 );
691 assert!(
692 !result.contains("[docs](https://example.com)"),
693 "the inline form must be gone after the atomic fix: {result:?}"
694 );
695 }
696
697 #[test]
698 fn test_apply_two_ref_emit_fixes_preserve_source_order() {
699 let content = "See [a](https://a.com) and [b](https://b.com).\n";
712 let span_a = content.find("[a](https://a.com)").unwrap()
713 ..content.find("](https://a.com)").unwrap() + "](https://a.com)".len();
714 let span_b = content.find("[b](https://b.com)").unwrap()
715 ..content.find("](https://b.com)").unwrap() + "](https://b.com)".len();
716 let warnings = vec![
717 LintWarning {
718 message: "Inconsistent link style".to_string(),
719 line: 1,
720 column: 5,
721 end_line: 1,
722 end_column: 0,
723 severity: Severity::Warning,
724 fix: Some(Fix::with_additional_edits(
725 span_a,
726 "[a]".to_string(),
727 vec![Fix::new(
728 content.len()..content.len(),
729 "[a]: https://a.com\n".to_string(),
730 )],
731 )),
732 rule_name: Some("MD054".to_string()),
733 },
734 LintWarning {
735 message: "Inconsistent link style".to_string(),
736 line: 1,
737 column: 28,
738 end_line: 1,
739 end_column: 0,
740 severity: Severity::Warning,
741 fix: Some(Fix::with_additional_edits(
742 span_b,
743 "[b]".to_string(),
744 vec![Fix::new(
745 content.len()..content.len(),
746 "[b]: https://b.com\n".to_string(),
747 )],
748 )),
749 rule_name: Some("MD054".to_string()),
750 },
751 ];
752
753 let result = apply_warning_fixes(content, &warnings).unwrap();
754
755 assert!(
757 result.contains("See [a] and [b]."),
758 "primary rewrites missing: {result:?}"
759 );
760 assert!(!result.contains("[a](https://a.com)"));
761 assert!(!result.contains("[b](https://b.com)"));
762
763 let pos_a = result.find("[a]: https://a.com").expect("ref-def for [a] missing");
765 let pos_b = result.find("[b]: https://b.com").expect("ref-def for [b] missing");
766 assert!(
767 pos_a < pos_b,
768 "ref-defs must appear in source order ([a] before [b]); got result:\n{result}"
769 );
770 }
771
772 #[test]
773 fn test_warning_fix_to_edit() {
774 let content = "Hello world";
775 let warning = LintWarning {
776 message: "Test".to_string(),
777 line: 1,
778 column: 1,
779 end_line: 1,
780 end_column: 5,
781 severity: Severity::Warning,
782 fix: Some(Fix::new(0..5, "Hi".to_string())),
783 rule_name: Some("TEST".to_string()),
784 };
785
786 let edit = warning_fix_to_edit(content, &warning).unwrap();
787 assert_eq!(edit, (0, 5, "Hi".to_string()));
788 }
789
790 #[test]
791 fn test_warning_fix_to_edit_no_fix() {
792 let content = "Hello world";
793 let warning = LintWarning {
794 message: "Test".to_string(),
795 line: 1,
796 column: 1,
797 end_line: 1,
798 end_column: 5,
799 severity: Severity::Warning,
800 fix: None,
801 rule_name: Some("TEST".to_string()),
802 };
803
804 let result = warning_fix_to_edit(content, &warning);
805 assert!(result.is_err());
806 assert_eq!(result.unwrap_err(), "Warning has no fix");
807 }
808
809 #[test]
810 fn test_warning_fix_to_edit_invalid_range() {
811 let content = "Short";
812 let warning = LintWarning {
813 message: "Test".to_string(),
814 line: 1,
815 column: 1,
816 end_line: 1,
817 end_column: 10,
818 severity: Severity::Warning,
819 fix: Some(Fix::new(0..100, "Long replacement".to_string())),
820 rule_name: Some("TEST".to_string()),
821 };
822
823 let result = warning_fix_to_edit(content, &warning);
824 assert!(result.is_err());
825 assert!(result.unwrap_err().contains("exceeds content length"));
826 }
827
828 #[test]
829 fn test_validate_fix_range() {
830 let content = "Hello world";
831
832 let valid_fix = Fix::new(0..5, "Hi".to_string());
834 assert!(validate_fix_range(content, &valid_fix).is_ok());
835
836 let invalid_fix = Fix::new(0..20, "Hi".to_string());
838 assert!(validate_fix_range(content, &invalid_fix).is_err());
839
840 let start = 5;
842 let end = 3;
843 let invalid_fix2 = Fix::new(start..end, "Hi".to_string());
844 assert!(validate_fix_range(content, &invalid_fix2).is_err());
845 }
846
847 #[test]
848 fn test_validate_fix_range_edge_cases() {
849 let content = "Test";
850
851 let fix1 = Fix::new(0..0, "Insert".to_string());
853 assert!(validate_fix_range(content, &fix1).is_ok());
854
855 let fix2 = Fix::new(4..4, " append".to_string());
857 assert!(validate_fix_range(content, &fix2).is_ok());
858
859 let fix3 = Fix::new(0..4, "Replace".to_string());
861 assert!(validate_fix_range(content, &fix3).is_ok());
862
863 let fix4 = Fix::new(10..11, "Invalid".to_string());
865 let result = validate_fix_range(content, &fix4);
866 assert!(result.is_err());
867 assert!(result.unwrap_err().contains("start 10 exceeds"));
868 }
869
870 #[test]
871 fn test_fix_ordering_stability() {
872 let content = "Test content here";
874 let warnings = vec![
875 LintWarning {
876 message: "First warning".to_string(),
877 line: 1,
878 column: 6,
879 end_line: 1,
880 end_column: 13,
881 severity: Severity::Warning,
882 fix: Some(Fix::new(5..12, "stuff".to_string())),
883 rule_name: Some("MD001".to_string()),
884 },
885 LintWarning {
886 message: "Second warning".to_string(),
887 line: 1,
888 column: 6,
889 end_line: 1,
890 end_column: 13,
891 severity: Severity::Warning,
892 fix: Some(Fix::new(5..12, "stuff".to_string())),
893 rule_name: Some("MD002".to_string()),
894 },
895 ];
896
897 let result = apply_warning_fixes(content, &warnings).unwrap();
899 assert_eq!(result, "Test stuff here");
900 }
901
902 #[test]
903 fn test_line_ending_preservation() {
904 let content_unix = "Line 1\nLine 2\n";
906 let warning = LintWarning {
907 message: "Add text".to_string(),
908 line: 1,
909 column: 7,
910 end_line: 1,
911 end_column: 7,
912 severity: Severity::Warning,
913 fix: Some(Fix::new(6..6, " added".to_string())),
914 rule_name: Some("TEST".to_string()),
915 };
916
917 let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
918 assert_eq!(result, "Line 1 added\nLine 2\n");
919
920 let content_windows = "Line 1\r\nLine 2\r\n";
922 let warning_windows = LintWarning {
923 message: "Add text".to_string(),
924 line: 1,
925 column: 7,
926 end_line: 1,
927 end_column: 7,
928 severity: Severity::Warning,
929 fix: Some(Fix::new(6..6, " added".to_string())),
930 rule_name: Some("TEST".to_string()),
931 };
932
933 let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
934 assert!(result_windows.starts_with("Line 1 added"));
936 assert!(result_windows.contains("Line 2"));
937 }
938
939 fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
940 LintWarning {
941 message: "test".to_string(),
942 line,
943 column: 1,
944 end_line,
945 end_column: 1,
946 severity: Severity::Warning,
947 fix: Some(Fix::new(0..1, "x".to_string())),
948 rule_name: Some(rule_name.to_string()),
949 }
950 }
951
952 #[test]
953 fn test_filter_warnings_disable_enable_block() {
954 let content =
955 "# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
956 let inline_config = InlineConfig::from_content(content);
957
958 let warnings = vec![
959 make_warning(4, 4, "MD013"), make_warning(6, 6, "MD013"), ];
962
963 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
964 assert_eq!(filtered.len(), 1);
965 assert_eq!(filtered[0].line, 6);
966 }
967
968 #[test]
969 fn test_filter_warnings_disable_line() {
970 let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
971 let inline_config = InlineConfig::from_content(content);
972
973 let warnings = vec![
974 make_warning(1, 1, "MD009"), make_warning(2, 2, "MD009"), ];
977
978 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
979 assert_eq!(filtered.len(), 1);
980 assert_eq!(filtered[0].line, 2);
981 }
982
983 #[test]
984 fn test_filter_warnings_disable_next_line() {
985 let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
986 let inline_config = InlineConfig::from_content(content);
987
988 let warnings = vec![
989 make_warning(2, 2, "MD034"), make_warning(3, 3, "MD034"), ];
992
993 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
994 assert_eq!(filtered.len(), 1);
995 assert_eq!(filtered[0].line, 3);
996 }
997
998 #[test]
999 fn test_filter_warnings_sub_rule_name() {
1000 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
1001 let inline_config = InlineConfig::from_content(content);
1002
1003 let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
1005
1006 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
1007 assert_eq!(filtered.len(), 1);
1008 assert_eq!(filtered[0].line, 4);
1009 }
1010
1011 #[test]
1012 fn test_filter_warnings_multi_line_warning() {
1013 let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
1015 let inline_config = InlineConfig::from_content(content);
1016
1017 let warnings = vec![
1018 make_warning(3, 5, "MD013"), make_warning(6, 6, "MD013"), ];
1021
1022 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1023 assert_eq!(filtered.len(), 1);
1025 assert_eq!(filtered[0].line, 6);
1026 }
1027
1028 #[test]
1029 fn test_filter_warnings_empty_input() {
1030 let inline_config = InlineConfig::from_content("");
1031 let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
1032 assert!(filtered.is_empty());
1033 }
1034
1035 #[test]
1036 fn test_filter_warnings_none_disabled() {
1037 let content = "line 1\nline 2\n";
1038 let inline_config = InlineConfig::from_content(content);
1039
1040 let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
1041
1042 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1043 assert_eq!(filtered.len(), 2);
1044 }
1045
1046 #[test]
1047 fn test_filter_warnings_all_disabled() {
1048 let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
1049 let inline_config = InlineConfig::from_content(content);
1050
1051 let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
1052
1053 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1054 assert!(filtered.is_empty());
1055 }
1056
1057 #[test]
1058 fn test_filter_warnings_end_line_zero_fallback() {
1059 let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
1061 let inline_config = InlineConfig::from_content(content);
1062
1063 let warnings = vec![make_warning(1, 0, "MD013")]; let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1066 assert!(filtered.is_empty());
1067 }
1068
1069 #[test]
1070 fn test_filter_non_md_rule_name_preserves_dash() {
1071 let content = "line 1\nline 2\n";
1074 let inline_config = InlineConfig::from_content(content);
1075
1076 let warnings = vec![make_warning(1, 1, "custom-rule")];
1077
1078 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
1080 assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
1081 }
1082
1083 #[test]
1084 fn test_filter_md_sub_rule_name_is_split() {
1085 let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
1087 let inline_config = InlineConfig::from_content(content);
1088
1089 let warnings = vec![
1090 make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029"), ];
1093
1094 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
1096 assert_eq!(filtered.len(), 1);
1097 assert_eq!(filtered[0].line, 4);
1098 }
1099
1100 #[test]
1101 fn test_filter_warnings_capture_restore() {
1102 let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
1103 let inline_config = InlineConfig::from_content(content);
1104
1105 let warnings = vec![
1106 make_warning(2, 2, "MD013"), make_warning(5, 5, "MD013"), make_warning(7, 7, "MD013"), ];
1110
1111 let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
1112 assert_eq!(filtered.len(), 1);
1113 assert_eq!(filtered[0].line, 5);
1114 }
1115}