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