1use crate::rule::{Fix, LintWarning};
7use crate::utils::ensure_consistent_line_endings;
8
9pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
12 let mut fixes: Vec<(usize, &Fix)> = warnings
13 .iter()
14 .enumerate()
15 .filter_map(|(i, w)| w.fix.as_ref().map(|fix| (i, fix)))
16 .collect();
17
18 fixes.sort_by(|(_, fix_a), (_, fix_b)| {
21 let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
22 if range_cmp != std::cmp::Ordering::Equal {
23 return range_cmp;
24 }
25 fix_a.range.end.cmp(&fix_b.range.end)
26 });
27
28 let mut deduplicated = Vec::new();
29 let mut i = 0;
30 while i < fixes.len() {
31 let (idx, current_fix) = fixes[i];
32 deduplicated.push((idx, current_fix));
33
34 while i + 1 < fixes.len() {
36 let (_, next_fix) = fixes[i + 1];
37 if current_fix.range == next_fix.range && current_fix.replacement == next_fix.replacement {
38 i += 1; } else {
40 break;
41 }
42 }
43 i += 1;
44 }
45
46 let mut fixes = deduplicated;
47
48 fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
51 let range_cmp = fix_b.range.start.cmp(&fix_a.range.start);
53 if range_cmp != std::cmp::Ordering::Equal {
54 return range_cmp;
55 }
56
57 let end_cmp = fix_b.range.end.cmp(&fix_a.range.end);
59 if end_cmp != std::cmp::Ordering::Equal {
60 return end_cmp;
61 }
62
63 idx_a.cmp(idx_b)
65 });
66
67 let mut result = content.to_string();
68
69 for (_, fix) in fixes {
70 if fix.range.end > result.len() {
72 return Err(format!(
73 "Fix range end {} exceeds content length {}",
74 fix.range.end,
75 result.len()
76 ));
77 }
78
79 if fix.range.start > fix.range.end {
80 return Err(format!(
81 "Invalid fix range: start {} > end {}",
82 fix.range.start, fix.range.end
83 ));
84 }
85
86 result.replace_range(fix.range.clone(), &fix.replacement);
88 }
89
90 Ok(ensure_consistent_line_endings(content, &result))
92}
93
94pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
97 if let Some(fix) = &warning.fix {
98 if fix.range.end > content.len() {
100 return Err(format!(
101 "Fix range end {} exceeds content length {}",
102 fix.range.end,
103 content.len()
104 ));
105 }
106
107 Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
108 } else {
109 Err("Warning has no fix".to_string())
110 }
111}
112
113pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
115 if fix.range.start > content.len() {
116 return Err(format!(
117 "Fix range start {} exceeds content length {}",
118 fix.range.start,
119 content.len()
120 ));
121 }
122
123 if fix.range.end > content.len() {
124 return Err(format!(
125 "Fix range end {} exceeds content length {}",
126 fix.range.end,
127 content.len()
128 ));
129 }
130
131 if fix.range.start > fix.range.end {
132 return Err(format!(
133 "Invalid fix range: start {} > end {}",
134 fix.range.start, fix.range.end
135 ));
136 }
137
138 Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::rule::{Fix, LintWarning, Severity};
145
146 #[test]
147 fn test_apply_single_fix() {
148 let content = "1. Multiple spaces";
149 let warning = LintWarning {
150 message: "Too many spaces".to_string(),
151 line: 1,
152 column: 3,
153 end_line: 1,
154 end_column: 5,
155 severity: Severity::Warning,
156 fix: Some(Fix {
157 range: 2..4, replacement: " ".to_string(), }),
160 rule_name: Some("MD030"),
161 };
162
163 let result = apply_warning_fixes(content, &[warning]).unwrap();
164 assert_eq!(result, "1. Multiple spaces");
165 }
166
167 #[test]
168 fn test_apply_multiple_fixes() {
169 let content = "1. First\n* Second";
170 let warnings = vec![
171 LintWarning {
172 message: "Too many spaces".to_string(),
173 line: 1,
174 column: 3,
175 end_line: 1,
176 end_column: 5,
177 severity: Severity::Warning,
178 fix: Some(Fix {
179 range: 2..4, replacement: " ".to_string(),
181 }),
182 rule_name: Some("MD030"),
183 },
184 LintWarning {
185 message: "Too many spaces".to_string(),
186 line: 2,
187 column: 2,
188 end_line: 2,
189 end_column: 5,
190 severity: Severity::Warning,
191 fix: Some(Fix {
192 range: 11..14, replacement: " ".to_string(),
194 }),
195 rule_name: Some("MD030"),
196 },
197 ];
198
199 let result = apply_warning_fixes(content, &warnings).unwrap();
200 assert_eq!(result, "1. First\n* Second");
201 }
202
203 #[test]
204 fn test_apply_non_overlapping_fixes() {
205 let content = "Test multiple spaces";
210 let warnings = vec![
211 LintWarning {
212 message: "Too many spaces".to_string(),
213 line: 1,
214 column: 5,
215 end_line: 1,
216 end_column: 7,
217 severity: Severity::Warning,
218 fix: Some(Fix {
219 range: 4..6, replacement: " ".to_string(),
221 }),
222 rule_name: Some("MD009"),
223 },
224 LintWarning {
225 message: "Too many spaces".to_string(),
226 line: 1,
227 column: 15,
228 end_line: 1,
229 end_column: 19,
230 severity: Severity::Warning,
231 fix: Some(Fix {
232 range: 14..18, replacement: " ".to_string(),
234 }),
235 rule_name: Some("MD009"),
236 },
237 ];
238
239 let result = apply_warning_fixes(content, &warnings).unwrap();
240 assert_eq!(result, "Test multiple spaces");
241 }
242
243 #[test]
244 fn test_apply_duplicate_fixes() {
245 let content = "Test content";
246 let warnings = vec![
247 LintWarning {
248 message: "Fix 1".to_string(),
249 line: 1,
250 column: 5,
251 end_line: 1,
252 end_column: 7,
253 severity: Severity::Warning,
254 fix: Some(Fix {
255 range: 4..6,
256 replacement: " ".to_string(),
257 }),
258 rule_name: Some("MD009"),
259 },
260 LintWarning {
261 message: "Fix 2 (duplicate)".to_string(),
262 line: 1,
263 column: 5,
264 end_line: 1,
265 end_column: 7,
266 severity: Severity::Warning,
267 fix: Some(Fix {
268 range: 4..6,
269 replacement: " ".to_string(),
270 }),
271 rule_name: Some("MD009"),
272 },
273 ];
274
275 let result = apply_warning_fixes(content, &warnings).unwrap();
277 assert_eq!(result, "Test content");
278 }
279
280 #[test]
281 fn test_apply_fixes_with_windows_line_endings() {
282 let content = "1. First\r\n* Second\r\n";
283 let warnings = vec![
284 LintWarning {
285 message: "Too many spaces".to_string(),
286 line: 1,
287 column: 3,
288 end_line: 1,
289 end_column: 5,
290 severity: Severity::Warning,
291 fix: Some(Fix {
292 range: 2..4,
293 replacement: " ".to_string(),
294 }),
295 rule_name: Some("MD030"),
296 },
297 LintWarning {
298 message: "Too many spaces".to_string(),
299 line: 2,
300 column: 2,
301 end_line: 2,
302 end_column: 5,
303 severity: Severity::Warning,
304 fix: Some(Fix {
305 range: 12..15, replacement: " ".to_string(),
307 }),
308 rule_name: Some("MD030"),
309 },
310 ];
311
312 let result = apply_warning_fixes(content, &warnings).unwrap();
313 assert!(result.contains("1. First"));
316 assert!(result.contains("* Second"));
317 }
318
319 #[test]
320 fn test_apply_fix_with_invalid_range() {
321 let content = "Short";
322 let warning = LintWarning {
323 message: "Invalid fix".to_string(),
324 line: 1,
325 column: 1,
326 end_line: 1,
327 end_column: 10,
328 severity: Severity::Warning,
329 fix: Some(Fix {
330 range: 0..100, replacement: "Replacement".to_string(),
332 }),
333 rule_name: Some("TEST"),
334 };
335
336 let result = apply_warning_fixes(content, &[warning]);
337 assert!(result.is_err());
338 assert!(result.unwrap_err().contains("exceeds content length"));
339 }
340
341 #[test]
342 fn test_apply_fix_with_reversed_range() {
343 let content = "Hello world";
344 let warning = LintWarning {
345 message: "Invalid fix".to_string(),
346 line: 1,
347 column: 5,
348 end_line: 1,
349 end_column: 3,
350 severity: Severity::Warning,
351 fix: Some(Fix {
352 #[allow(clippy::reversed_empty_ranges)]
353 range: 10..5, replacement: "Test".to_string(),
355 }),
356 rule_name: Some("TEST"),
357 };
358
359 let result = apply_warning_fixes(content, &[warning]);
360 assert!(result.is_err());
361 assert!(result.unwrap_err().contains("Invalid fix range"));
362 }
363
364 #[test]
365 fn test_apply_no_fixes() {
366 let content = "No changes needed";
367 let warnings = vec![LintWarning {
368 message: "Warning without fix".to_string(),
369 line: 1,
370 column: 1,
371 end_line: 1,
372 end_column: 5,
373 severity: Severity::Warning,
374 fix: None,
375 rule_name: Some("TEST"),
376 }];
377
378 let result = apply_warning_fixes(content, &warnings).unwrap();
379 assert_eq!(result, content);
380 }
381
382 #[test]
383 fn test_warning_fix_to_edit() {
384 let content = "Hello world";
385 let warning = LintWarning {
386 message: "Test".to_string(),
387 line: 1,
388 column: 1,
389 end_line: 1,
390 end_column: 5,
391 severity: Severity::Warning,
392 fix: Some(Fix {
393 range: 0..5,
394 replacement: "Hi".to_string(),
395 }),
396 rule_name: Some("TEST"),
397 };
398
399 let edit = warning_fix_to_edit(content, &warning).unwrap();
400 assert_eq!(edit, (0, 5, "Hi".to_string()));
401 }
402
403 #[test]
404 fn test_warning_fix_to_edit_no_fix() {
405 let content = "Hello world";
406 let warning = LintWarning {
407 message: "Test".to_string(),
408 line: 1,
409 column: 1,
410 end_line: 1,
411 end_column: 5,
412 severity: Severity::Warning,
413 fix: None,
414 rule_name: Some("TEST"),
415 };
416
417 let result = warning_fix_to_edit(content, &warning);
418 assert!(result.is_err());
419 assert_eq!(result.unwrap_err(), "Warning has no fix");
420 }
421
422 #[test]
423 fn test_warning_fix_to_edit_invalid_range() {
424 let content = "Short";
425 let warning = LintWarning {
426 message: "Test".to_string(),
427 line: 1,
428 column: 1,
429 end_line: 1,
430 end_column: 10,
431 severity: Severity::Warning,
432 fix: Some(Fix {
433 range: 0..100,
434 replacement: "Long replacement".to_string(),
435 }),
436 rule_name: Some("TEST"),
437 };
438
439 let result = warning_fix_to_edit(content, &warning);
440 assert!(result.is_err());
441 assert!(result.unwrap_err().contains("exceeds content length"));
442 }
443
444 #[test]
445 fn test_validate_fix_range() {
446 let content = "Hello world";
447
448 let valid_fix = Fix {
450 range: 0..5,
451 replacement: "Hi".to_string(),
452 };
453 assert!(validate_fix_range(content, &valid_fix).is_ok());
454
455 let invalid_fix = Fix {
457 range: 0..20,
458 replacement: "Hi".to_string(),
459 };
460 assert!(validate_fix_range(content, &invalid_fix).is_err());
461
462 let start = 5;
464 let end = 3;
465 let invalid_fix2 = Fix {
466 range: start..end,
467 replacement: "Hi".to_string(),
468 };
469 assert!(validate_fix_range(content, &invalid_fix2).is_err());
470 }
471
472 #[test]
473 fn test_validate_fix_range_edge_cases() {
474 let content = "Test";
475
476 let fix1 = Fix {
478 range: 0..0,
479 replacement: "Insert".to_string(),
480 };
481 assert!(validate_fix_range(content, &fix1).is_ok());
482
483 let fix2 = Fix {
485 range: 4..4,
486 replacement: " append".to_string(),
487 };
488 assert!(validate_fix_range(content, &fix2).is_ok());
489
490 let fix3 = Fix {
492 range: 0..4,
493 replacement: "Replace".to_string(),
494 };
495 assert!(validate_fix_range(content, &fix3).is_ok());
496
497 let fix4 = Fix {
499 range: 10..11,
500 replacement: "Invalid".to_string(),
501 };
502 let result = validate_fix_range(content, &fix4);
503 assert!(result.is_err());
504 assert!(result.unwrap_err().contains("start 10 exceeds"));
505 }
506
507 #[test]
508 fn test_fix_ordering_stability() {
509 let content = "Test content here";
511 let warnings = vec![
512 LintWarning {
513 message: "First warning".to_string(),
514 line: 1,
515 column: 6,
516 end_line: 1,
517 end_column: 13,
518 severity: Severity::Warning,
519 fix: Some(Fix {
520 range: 5..12, replacement: "stuff".to_string(),
522 }),
523 rule_name: Some("MD001"),
524 },
525 LintWarning {
526 message: "Second warning".to_string(),
527 line: 1,
528 column: 6,
529 end_line: 1,
530 end_column: 13,
531 severity: Severity::Warning,
532 fix: Some(Fix {
533 range: 5..12, replacement: "stuff".to_string(),
535 }),
536 rule_name: Some("MD002"),
537 },
538 ];
539
540 let result = apply_warning_fixes(content, &warnings).unwrap();
542 assert_eq!(result, "Test stuff here");
543 }
544
545 #[test]
546 fn test_line_ending_preservation() {
547 let content_unix = "Line 1\nLine 2\n";
549 let warning = LintWarning {
550 message: "Add text".to_string(),
551 line: 1,
552 column: 7,
553 end_line: 1,
554 end_column: 7,
555 severity: Severity::Warning,
556 fix: Some(Fix {
557 range: 6..6,
558 replacement: " added".to_string(),
559 }),
560 rule_name: Some("TEST"),
561 };
562
563 let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
564 assert_eq!(result, "Line 1 added\nLine 2\n");
565
566 let content_windows = "Line 1\r\nLine 2\r\n";
568 let warning_windows = LintWarning {
569 message: "Add text".to_string(),
570 line: 1,
571 column: 7,
572 end_line: 1,
573 end_column: 7,
574 severity: Severity::Warning,
575 fix: Some(Fix {
576 range: 6..6,
577 replacement: " added".to_string(),
578 }),
579 rule_name: Some("TEST"),
580 };
581
582 let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
583 assert!(result_windows.starts_with("Line 1 added"));
585 assert!(result_windows.contains("Line 2"));
586 }
587}