1use collections::HashMap;
2
3use crate::{
4 example::ActualCursor,
5 reorder_patch::{Patch, PatchLine},
6};
7
8pub type Counts = HashMap<String, usize>;
9type CountsDelta = HashMap<String, isize>;
10
11/// Context characters needed on each side of a change to capture all affected n-grams
12const CONTEXT_CHARS: usize = CHR_F_CHAR_ORDER - 1;
13
14#[derive(Default, Debug, Clone)]
15pub struct ClassificationMetrics {
16 pub true_positives: usize,
17 pub false_positives: usize,
18 pub false_negatives: usize,
19}
20
21impl ClassificationMetrics {
22 pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics {
23 let mut true_positives = 0;
24 let mut false_positives = 0;
25 let mut false_negatives = 0;
26
27 for (ngram, &expected_count) in expected {
28 let actual_count = *actual.get(ngram).unwrap_or(&0);
29 if actual_count > expected_count {
30 false_positives += actual_count - expected_count;
31 } else {
32 false_negatives += expected_count - actual_count;
33 }
34 true_positives += expected_count.min(actual_count);
35 }
36
37 for (ngram, &actual_count) in actual {
38 if !expected.contains_key(ngram) {
39 false_positives += actual_count;
40 }
41 }
42
43 ClassificationMetrics {
44 true_positives,
45 false_positives,
46 false_negatives,
47 }
48 }
49
50 pub fn precision(&self) -> f64 {
51 if self.true_positives + self.false_positives == 0 {
52 0.0
53 } else {
54 self.true_positives as f64 / (self.true_positives + self.false_positives) as f64
55 }
56 }
57
58 pub fn recall(&self) -> f64 {
59 if self.true_positives + self.false_negatives == 0 {
60 0.0
61 } else {
62 self.true_positives as f64 / (self.true_positives + self.false_negatives) as f64
63 }
64 }
65
66 pub fn f1(&self) -> f64 {
67 let precision = self.precision();
68 let recall = self.recall();
69 if precision + recall == 0.0 {
70 0.0
71 } else {
72 2.0 * precision * recall / (precision + recall)
73 }
74 }
75}
76
77enum ChrfWhitespace {
78 #[allow(unused)]
79 Unchanged,
80 Ignore,
81}
82
83const CHR_F_CHAR_ORDER: usize = 6;
84const CHR_F_BETA: f64 = 2.0;
85const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Ignore;
86
87/// Computes a delta-chrF score that compares two sets of edits.
88///
89/// This metric works by:
90/// 1. Computing n-gram count differences (deltas) between original→expected and original→actual
91/// 2. Comparing these deltas to measure how well actual edits match expected edits
92///
93/// Returns a score from 0.0 to 100.0, where 100.0 means the actual edits perfectly match
94/// the expected edits.
95pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 {
96 // Edge case: if all texts are identical, the edits match perfectly
97 if original == expected && expected == actual {
98 return 100.0;
99 }
100
101 // Pre-filter whitespace once for all texts
102 let orig_chars: Vec<char> = filter_whitespace_chars(original);
103 let exp_chars: Vec<char> = filter_whitespace_chars(expected);
104 let act_chars: Vec<char> = filter_whitespace_chars(actual);
105
106 // Find the changed regions between original→expected and original→actual
107 // We only need to compute n-grams on these regions (plus context for boundary n-grams)
108 let (orig_for_exp, exp_region) = extract_changed_regions(&orig_chars, &exp_chars);
109 let (orig_for_act, act_region) = extract_changed_regions(&orig_chars, &act_chars);
110
111 let mut total_precision = 0.0;
112 let mut total_recall = 0.0;
113
114 for order in 1..=CHR_F_CHAR_ORDER {
115 // Compute n-grams only on the affected regions
116 let orig_ngrams_for_exp = count_ngrams_from_chars(&orig_for_exp, order);
117 let exp_ngrams = count_ngrams_from_chars(&exp_region, order);
118 let expected_delta = compute_ngram_delta(&exp_ngrams, &orig_ngrams_for_exp);
119
120 let orig_ngrams_for_act = count_ngrams_from_chars(&orig_for_act, order);
121 let act_ngrams = count_ngrams_from_chars(&act_region, order);
122 let actual_delta = compute_ngram_delta(&act_ngrams, &orig_ngrams_for_act);
123
124 if expected_delta.is_empty() && actual_delta.is_empty() {
125 total_precision += 1.0;
126 total_recall += 1.0;
127 continue;
128 }
129
130 let expected_counts = ngram_delta_to_counts(&expected_delta);
131 let actual_counts = ngram_delta_to_counts(&actual_delta);
132
133 let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
134 total_precision += score.precision();
135 total_recall += score.recall();
136 }
137
138 let prec = total_precision / CHR_F_CHAR_ORDER as f64;
139 let recall = total_recall / CHR_F_CHAR_ORDER as f64;
140 let f_score = if prec + recall == 0.0 {
141 0.0
142 } else {
143 (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
144 };
145
146 f_score * 100.0
147}
148
149/// Reference implementation of delta_chr_f (original, non-optimized version).
150/// Used for testing that the optimized version produces identical results.
151#[cfg(test)]
152fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 {
153 if original == expected && expected == actual {
154 return 100.0;
155 }
156
157 let original_ngrams = chr_f_ngram_counts(original);
158 let expected_ngrams = chr_f_ngram_counts(expected);
159 let actual_ngrams = chr_f_ngram_counts(actual);
160
161 let mut total_precision = 0.0;
162 let mut total_recall = 0.0;
163
164 for order in 0..CHR_F_CHAR_ORDER {
165 let expected_delta = compute_ngram_delta(&expected_ngrams[order], &original_ngrams[order]);
166 let actual_delta = compute_ngram_delta(&actual_ngrams[order], &original_ngrams[order]);
167
168 if expected_delta.is_empty() && actual_delta.is_empty() {
169 total_precision += 1.0;
170 total_recall += 1.0;
171 continue;
172 }
173
174 let expected_counts = ngram_delta_to_counts(&expected_delta);
175 let actual_counts = ngram_delta_to_counts(&actual_delta);
176
177 let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
178 total_precision += score.precision();
179 total_recall += score.recall();
180 }
181
182 let prec = total_precision / CHR_F_CHAR_ORDER as f64;
183 let recall = total_recall / CHR_F_CHAR_ORDER as f64;
184 let f_score = if prec + recall == 0.0 {
185 0.0
186 } else {
187 (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
188 };
189
190 f_score * 100.0
191}
192
193/// Filter whitespace from a string and return as Vec<char>
194fn filter_whitespace_chars(text: &str) -> Vec<char> {
195 match CHR_F_WHITESPACE {
196 ChrfWhitespace::Unchanged => text.chars().collect(),
197 ChrfWhitespace::Ignore => text.chars().filter(|c| !c.is_whitespace()).collect(),
198 }
199}
200
201/// Extract only the changed regions between two texts, with context for n-gram boundaries.
202///
203/// Returns (original_affected_region, modified_affected_region) as Vec<char>.
204///
205/// The key insight: when computing n-gram delta between two nearly-identical texts,
206/// n-grams from unchanged regions cancel out. We only need to process:
207/// 1. The changed content itself
208/// 2. CONTEXT_CHARS (n-1) characters before and after, to capture boundary-crossing n-grams
209fn extract_changed_regions(original: &[char], modified: &[char]) -> (Vec<char>, Vec<char>) {
210 // Find longest common prefix
211 let prefix_len = original
212 .iter()
213 .zip(modified.iter())
214 .take_while(|(a, b)| a == b)
215 .count();
216
217 // Find longest common suffix (that doesn't overlap with prefix)
218 let orig_remaining = original.len().saturating_sub(prefix_len);
219 let mod_remaining = modified.len().saturating_sub(prefix_len);
220 let max_suffix = orig_remaining.min(mod_remaining);
221
222 let suffix_len = original
223 .iter()
224 .rev()
225 .zip(modified.iter().rev())
226 .take(max_suffix)
227 .take_while(|(a, b)| a == b)
228 .count();
229
230 // Calculate the changed region boundaries
231 let orig_change_start = prefix_len;
232 let orig_change_end = original.len().saturating_sub(suffix_len);
233 let mod_change_start = prefix_len;
234 let mod_change_end = modified.len().saturating_sub(suffix_len);
235
236 // If there's no actual change, return empty regions
237 if orig_change_start >= orig_change_end && mod_change_start >= mod_change_end {
238 return (Vec::new(), Vec::new());
239 }
240
241 // Expand to include context for n-gram boundaries
242 let orig_context_start = orig_change_start.saturating_sub(CONTEXT_CHARS);
243 let orig_context_end = (orig_change_end + CONTEXT_CHARS).min(original.len());
244 let mod_context_start = mod_change_start.saturating_sub(CONTEXT_CHARS);
245 let mod_context_end = (mod_change_end + CONTEXT_CHARS).min(modified.len());
246
247 let orig_region: Vec<char> = original[orig_context_start..orig_context_end].to_vec();
248 let mod_region: Vec<char> = modified[mod_context_start..mod_context_end].to_vec();
249
250 (orig_region, mod_region)
251}
252
253/// Count n-grams directly from a char slice (avoids String allocation for the full text)
254fn count_ngrams_from_chars(chars: &[char], n: usize) -> Counts {
255 let mut counts = Counts::default();
256
257 if chars.len() < n {
258 return counts;
259 }
260
261 for window in chars.windows(n) {
262 let ngram: String = window.iter().collect();
263 *counts.entry(ngram).or_insert(0) += 1;
264 }
265
266 counts
267}
268
269#[allow(dead_code)]
270fn chr_f_ngram_counts(text: &str) -> Vec<Counts> {
271 // Ignore whitespace. The original chrF implementation skips all
272 // whitespace. We should consider compressing multiple consecutive
273 // spaces into one -- this may reflect our task more closely.
274 let text = match CHR_F_WHITESPACE {
275 ChrfWhitespace::Unchanged => text.to_string(),
276 ChrfWhitespace::Ignore => text
277 .chars()
278 .filter(|c| !c.is_whitespace())
279 .collect::<String>(),
280 };
281
282 (1..=CHR_F_CHAR_ORDER)
283 .map(|order| count_ngrams(&text, order))
284 .collect()
285}
286
287fn compute_ngram_delta(after: &Counts, before: &Counts) -> CountsDelta {
288 let mut delta = CountsDelta::default();
289
290 for (ngram, &before_count) in before {
291 let after_count = *after.get(ngram).unwrap_or(&0);
292 delta.insert(ngram.clone(), after_count as isize - before_count as isize);
293 }
294
295 for (ngram, &after_count) in after {
296 if !before.contains_key(ngram) {
297 delta.insert(ngram.clone(), after_count as isize);
298 }
299 }
300
301 delta
302}
303
304/// Convert negative counts to special deletion tokens.
305/// For example, if expected delta is {"foo": -1} and actual delta is {"bar": -1},
306/// we convert it to {"¬foo": +1} and {"¬bar": +1}. This way _not_ deleting "foo"
307/// will result in a false negative, and mistakenly deleting "bar" will result in a false positive.
308fn ngram_delta_to_counts(delta: &CountsDelta) -> Counts {
309 let mut counts = Counts::default();
310
311 for (ngram, &delta) in delta {
312 if delta > 0 {
313 counts.insert(ngram.clone(), delta as usize);
314 } else if delta < 0 {
315 counts.insert(format!("¬{ngram}"), delta.unsigned_abs());
316 }
317 }
318
319 counts
320}
321
322#[allow(dead_code)]
323fn count_ngrams(text: &str, n: usize) -> Counts {
324 let chars: Vec<char> = text.chars().collect();
325 let mut counts = Counts::default();
326
327 for window in chars.windows(n) {
328 let ngram: String = window.iter().collect();
329 *counts.entry(ngram).or_insert(0) += 1;
330 }
331
332 counts
333}
334
335pub fn braces_disbalance(text: &str) -> usize {
336 let mut disbalance = 0isize;
337
338 let a = text.chars().filter(|&c| c == '{').count() as isize;
339 let b = text.chars().filter(|&c| c == '}').count() as isize;
340 disbalance += (a - b).abs();
341
342 let a = text.chars().filter(|&c| c == '(').count() as isize;
343 let b = text.chars().filter(|&c| c == ')').count() as isize;
344 disbalance += (a - b).abs();
345
346 let a = text.chars().filter(|&c| c == '[').count() as isize;
347 let b = text.chars().filter(|&c| c == ']').count() as isize;
348 disbalance += (a - b).abs();
349
350 disbalance as usize
351}
352
353/// Extracts changed lines from a unified diff string.
354/// Returns a bag (multiset) of lines that were added (+) or removed (-).
355/// The +/- prefix is included in the line to distinguish additions from deletions.
356pub fn extract_changed_lines_from_diff(diff: &str) -> Counts {
357 let mut counts = Counts::default();
358
359 for line in diff.lines() {
360 // Skip file headers (--- and +++)
361 if line.starts_with("---") || line.starts_with("+++") {
362 continue;
363 }
364 // Skip hunk headers (@@)
365 if line.starts_with("@@") {
366 continue;
367 }
368 // Skip diff header lines (diff --git, index, etc.)
369 if line.starts_with("diff ") || line.starts_with("index ") {
370 continue;
371 }
372 // Include added and removed lines (with their prefix)
373 if line.starts_with('+') || line.starts_with('-') {
374 *counts.entry(line.to_string()).or_insert(0) += 1;
375 }
376 }
377
378 counts
379}
380
381/// Computes exact lines match metrics between expected and actual patches.
382/// Treats changed lines as a bag (multiset) - order is discarded but count matters.
383/// Returns ClassificationMetrics with TP/FP/FN counts.
384pub fn exact_lines_match(expected_patch: &str, actual_patch: &str) -> ClassificationMetrics {
385 let expected_lines = extract_changed_lines_from_diff(expected_patch);
386 let actual_lines = extract_changed_lines_from_diff(actual_patch);
387 ClassificationMetrics::from_counts(&expected_lines, &actual_lines)
388}
389
390/// Returns whether the patch contains any isolated whitespace-only changes.
391///
392/// A whitespace-only change is an added or deleted line whose content is empty or
393/// contains only whitespace. It is "isolated" when it is not adjacent to any
394/// substantive (non-whitespace) change within the same contiguous change group.
395pub fn has_isolated_whitespace_changes(patch_str: &str, cursor: Option<&ActualCursor>) -> bool {
396 let patch = Patch::parse_unified_diff(patch_str);
397
398 let cursor_new_file_line = cursor.as_ref().map(|c| (c.row + 1) as usize);
399
400 for hunk in &patch.hunks {
401 let lines = &hunk.lines;
402 let mut new_text_line = hunk.new_start as usize;
403
404 for (i, line) in lines.iter().enumerate() {
405 let content = match line {
406 PatchLine::Addition(s) => {
407 let addition_line = new_text_line;
408 new_text_line += 1;
409 if s.trim().is_empty() && cursor_new_file_line == Some(addition_line) {
410 continue;
411 }
412 s.as_str()
413 }
414 PatchLine::Deletion(s) => s.as_str(),
415 PatchLine::Context(_) => {
416 new_text_line += 1;
417 continue;
418 }
419 _ => continue,
420 };
421
422 if !content.trim().is_empty() {
423 continue;
424 }
425
426 if is_whitespace_change_isolated(lines, i) {
427 return true;
428 }
429 }
430 }
431
432 false
433}
434
435fn is_whitespace_change_isolated(lines: &[PatchLine], index: usize) -> bool {
436 // Look backward for a non-whitespace change before hitting a context line
437 for line in lines[..index].iter().rev() {
438 match line {
439 PatchLine::Addition(s) | PatchLine::Deletion(s) => {
440 if !s.trim().is_empty() {
441 return false;
442 }
443 }
444 _ => break,
445 }
446 }
447
448 // Look forward for a non-whitespace change before hitting a context line
449 for line in &lines[index + 1..] {
450 match line {
451 PatchLine::Addition(s) | PatchLine::Deletion(s) => {
452 if !s.trim().is_empty() {
453 return false;
454 }
455 }
456 _ => break,
457 }
458 }
459
460 true
461}
462
463/// A simple proxy for whether the prediction respects editable region.
464pub fn is_editable_region_correct(actual_patch: &str) -> bool {
465 // A typical sign of a wrong editable region: a bunch of lines deletion
466 // at the beginning or end of the patch.
467 let patch = Patch::parse_unified_diff(actual_patch);
468 if patch.hunks.is_empty() {
469 return true;
470 }
471
472 let hunk = &patch.hunks[0];
473 let mut deletions_at_start = 0;
474
475 for line in hunk.lines.iter() {
476 match line {
477 PatchLine::Deletion(_) => deletions_at_start += 1,
478 _ => break,
479 }
480 }
481
482 if deletions_at_start >= 3 {
483 return false;
484 }
485
486 true
487}
488
489#[cfg(test)]
490mod test_optimization {
491 use super::*;
492
493 #[test]
494 fn test_extract_changed_regions_simple() {
495 let original: Vec<char> = "hello world".chars().collect();
496 let modified: Vec<char> = "hello there".chars().collect();
497
498 let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
499
500 // "world" vs "there" - with 5 chars context, we get "ello world" vs "ello there"
501 // (or less if not enough chars available)
502 assert!(orig_region.len() < original.len());
503 assert!(mod_region.len() < modified.len());
504 }
505
506 #[test]
507 fn test_extract_changed_regions_insertion() {
508 let original: Vec<char> = "abcdef".chars().collect();
509 let modified: Vec<char> = "abcXYZdef".chars().collect();
510
511 let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
512
513 // The insertion is between c and d, so we need context around that point
514 assert!(orig_region.len() <= original.len());
515 assert!(mod_region.iter().collect::<String>().contains("XYZ"));
516 }
517
518 #[test]
519 fn test_extract_changed_regions_identical() {
520 let text: Vec<char> = "identical text".chars().collect();
521
522 let (orig_region, mod_region) = extract_changed_regions(&text, &text);
523
524 // When texts are identical, regions should be empty
525 assert!(orig_region.is_empty());
526 assert!(mod_region.is_empty());
527 }
528
529 #[test]
530 fn test_optimized_matches_original_score() {
531 // Test that our optimized version produces the same results
532 let test_cases = vec![
533 ("hello world", "hello there", "hello world"),
534 (
535 "fn main() {}",
536 "fn main() { println!(); }",
537 "fn main() { print!(); }",
538 ),
539 ("abcdefghij", "abcXXXghij", "abcYYghij"),
540 ("unchanged", "unchanged", "unchanged"),
541 (
542 "prefix middle suffix",
543 "prefix CHANGED suffix",
544 "prefix middle suffix",
545 ),
546 ];
547
548 for (original, expected, actual) in test_cases {
549 let score = delta_chr_f(original, expected, actual);
550 // Just verify it produces a reasonable score (0-100)
551 assert!(
552 score >= 0.0 && score <= 100.0,
553 "Score {} out of range for ({}, {}, {})",
554 score,
555 original,
556 expected,
557 actual
558 );
559 }
560 }
561
562 #[test]
563 fn test_optimized_equals_reference() {
564 // Comprehensive test that optimized version matches reference implementation exactly
565 let test_cases = vec![
566 // Basic cases
567 ("hello world", "hello there", "hello world"),
568 ("hello world", "hello there", "hello there"),
569 ("unchanged", "unchanged", "unchanged"),
570 // Code-like cases
571 (
572 "fn main() { println!(\"Hello\"); }",
573 "fn main() { println!(\"Hello, World!\"); }",
574 "fn main() { println!(\"Hello, World!\"); }",
575 ),
576 (
577 "fn main() { println!(\"Hello\"); }",
578 "fn main() { println!(\"Hello, World!\"); }",
579 "fn main() { println!(\"Goodbye\"); }",
580 ),
581 // Insertion
582 ("abcdef", "abcXYZdef", "abcdef"),
583 ("abcdef", "abcXYZdef", "abcXYZdef"),
584 ("abcdef", "abcXYZdef", "abcABCdef"),
585 // Deletion
586 ("abcXYZdef", "abcdef", "abcXYZdef"),
587 ("abcXYZdef", "abcdef", "abcdef"),
588 // Multiple changes (simulated by different expected/actual)
589 ("one two three four", "one THREE four", "one two FOUR"),
590 // Edge cases
591 ("a", "b", "c"),
592 ("", "abc", ""),
593 ("abc", "", "abc"),
594 // Longer text with small change
595 (
596 "This is a longer piece of text that contains many words and characters to process",
597 "This is a longer piece of TEXT that contains many words and characters to process",
598 "This is a longer piece of text that contains many words and characters to process",
599 ),
600 // Change at the beginning
601 (
602 "ORIGINAL start of text",
603 "NEW start of text",
604 "DIFFERENT start of text",
605 ),
606 // Change at the end
607 (
608 "text ending ORIGINAL",
609 "text ending NEW",
610 "text ending DIFFERENT",
611 ),
612 // Whitespace (should be ignored)
613 ("hello world", "hello there", "hello world"),
614 ("a b c d", "a X c d", "a Y c d"),
615 ];
616
617 for (original, expected, actual) in test_cases {
618 let optimized_score = delta_chr_f(original, expected, actual);
619 let reference_score = delta_chr_f_reference(original, expected, actual);
620
621 assert!(
622 (optimized_score - reference_score).abs() < 1e-10,
623 "Mismatch for ({:?}, {:?}, {:?}):\n optimized: {}\n reference: {}",
624 original,
625 expected,
626 actual,
627 optimized_score,
628 reference_score
629 );
630 }
631 }
632}
633
634#[cfg(test)]
635mod test {
636 use super::*;
637 use crate::example::ActualCursor;
638 use indoc::indoc;
639
640 fn cursor_on_line(one_based_line: u32) -> ActualCursor {
641 ActualCursor {
642 path: String::new(),
643 row: one_based_line - 1,
644 column: 0,
645 offset: 0,
646 editable_region_offset: None,
647 }
648 }
649
650 #[test]
651 fn test_delta_chr_f_perfect_match() {
652 let original = "fn main() { println!(\"Hello\");}";
653 let expected = "fn main() { println!(\"Hello, World!\");}";
654
655 let score = delta_chr_f(original, expected, expected);
656 assert!((score - 100.0).abs() < 1e-2);
657 }
658
659 #[test]
660 fn test_delta_chr_f_wrong_edit() {
661 // When the edit is wrong
662 let original = "one two three";
663 let expected = "one three"; // deleted "two "
664 let actual = "one two four"; // deleted "three", added "four"
665
666 // Then the score should be low
667 let score = delta_chr_f(original, expected, actual);
668 assert!(score > 20.0 && score < 40.0);
669 }
670
671 #[test]
672 fn test_delta_chr_f_partial_match() {
673 let original = "let x = 42;";
674 let expected = "let x = 100;";
675 let actual = "let x = 99;";
676
677 // We got the edit location right, but the replacement text is wrong.
678 // Deleted ngrams will match, bringing the score somewhere in the middle.
679 let score = delta_chr_f(original, expected, actual);
680 assert!(score > 40.0 && score < 60.0);
681 }
682
683 #[test]
684 fn test_delta_chr_f_missed_edit() {
685 // When predictions makes no changes
686 let original = "prefix old suffix";
687 let expected = "prefix new suffix";
688 let actual = "prefix old suffix"; // no change
689
690 // Then the score should be low (all expected changes are false negatives)
691 let score = delta_chr_f(original, expected, actual);
692 assert!(score < 20.0);
693 }
694
695 #[test]
696 fn test_delta_chr_f_extra_edit() {
697 // When adding unexpected content
698 let original = "helloworld";
699 let expected = "helloworld"; // no change expected
700 let actual = "helloextraworld"; // added "extra"
701
702 // Then the score should be low (all actual changes are false positives)
703 let score = delta_chr_f(original, expected, actual);
704 assert!(score < 20.0);
705 }
706
707 #[test]
708 fn test_delta_chr_f_no_changes() {
709 let text = "unchanged text";
710 let score = delta_chr_f(text, text, text);
711 assert!((score - 100.0).abs() < 1e-2);
712 }
713
714 #[test]
715 fn test_braces_disbalance() {
716 let text = "let x = { 1 + 2 };";
717 assert_eq!(braces_disbalance(text), 0);
718
719 let text = "let x = { 1 + 2";
720 assert_eq!(braces_disbalance(text), 1);
721
722 let text = "let x = { 1 + 2 )";
723 assert_eq!(braces_disbalance(text), 2);
724 }
725
726 #[test]
727 fn test_extract_changed_lines_from_diff() {
728 let diff = r#"--- a/file.rs
729+++ b/file.rs
730@@ -1,3 +1,3 @@
731 fn main() {
732- println!("hello");
733+ println!("world");
734 }"#;
735
736 let counts = extract_changed_lines_from_diff(diff);
737 assert_eq!(counts.get("- println!(\"hello\");"), Some(&1));
738 assert_eq!(counts.get("+ println!(\"world\");"), Some(&1));
739 assert_eq!(counts.len(), 2);
740 }
741
742 #[test]
743 fn test_extract_changed_lines_skips_headers() {
744 let diff = r#"diff --git a/file.rs b/file.rs
745index abc123..def456 100644
746--- a/file.rs
747+++ b/file.rs
748@@ -1,2 +1,2 @@
749-old line
750+new line"#;
751
752 let counts = extract_changed_lines_from_diff(diff);
753 assert_eq!(counts.get("-old line"), Some(&1));
754 assert_eq!(counts.get("+new line"), Some(&1));
755 assert_eq!(counts.len(), 2);
756 }
757
758 #[test]
759 fn test_exact_lines_match_perfect() {
760 let expected = r#"--- a/file.rs
761+++ b/file.rs
762@@ -1,3 +1,3 @@
763-old line 1
764-old line 2
765+new line 1
766+new line 2"#;
767
768 let actual = r#"--- a/file.rs
769+++ b/file.rs
770@@ -1,3 +1,3 @@
771-old line 1
772-old line 2
773+new line 1
774+new line 2"#;
775
776 let metrics = exact_lines_match(expected, actual);
777 assert_eq!(metrics.true_positives, 4);
778 assert_eq!(metrics.false_positives, 0);
779 assert_eq!(metrics.false_negatives, 0);
780 assert!((metrics.precision() - 1.0).abs() < 1e-6);
781 assert!((metrics.recall() - 1.0).abs() < 1e-6);
782 assert!((metrics.f1() - 1.0).abs() < 1e-6);
783 }
784
785 #[test]
786 fn test_exact_lines_match_partial() {
787 let expected = r#"-old line 1
788-old line 2
789+new line 1
790+new line 2"#;
791
792 let actual = r#"-old line 1
793+new line 1
794+extra line"#;
795
796 let metrics = exact_lines_match(expected, actual);
797 // TP: "-old line 1" and "+new line 1" (2)
798 // FP: "+extra line" (1)
799 // FN: "-old line 2" and "+new line 2" (2)
800 assert_eq!(metrics.true_positives, 2);
801 assert_eq!(metrics.false_positives, 1);
802 assert_eq!(metrics.false_negatives, 2);
803 }
804
805 #[test]
806 fn test_exact_lines_match_no_overlap() {
807 let expected = r#"-line a
808+line b"#;
809
810 let actual = r#"-line x
811+line y"#;
812
813 let metrics = exact_lines_match(expected, actual);
814 assert_eq!(metrics.true_positives, 0);
815 assert_eq!(metrics.false_positives, 2);
816 assert_eq!(metrics.false_negatives, 2);
817 assert!((metrics.precision()).abs() < 1e-6);
818 assert!((metrics.recall()).abs() < 1e-6);
819 }
820
821 #[test]
822 fn test_exact_lines_match_duplicate_lines() {
823 let expected = r#"+line a
824+line a
825+line a"#;
826
827 let actual = r#"+line a
828+line a"#;
829
830 let metrics = exact_lines_match(expected, actual);
831 // Expected has 3 "+line a", actual has 2
832 // TP: 2, FN: 1, FP: 0
833 assert_eq!(metrics.true_positives, 2);
834 assert_eq!(metrics.false_positives, 0);
835 assert_eq!(metrics.false_negatives, 1);
836 }
837
838 #[test]
839 fn test_exact_lines_match_empty_patches() {
840 let metrics = exact_lines_match("", "");
841 assert_eq!(metrics.true_positives, 0);
842 assert_eq!(metrics.false_positives, 0);
843 assert_eq!(metrics.false_negatives, 0);
844 }
845
846 #[test]
847 fn test_is_editable_region_correct() {
848 let patch = indoc! {"
849 @@ -1,1 +1,1 @@
850 -context
851 -removed
852 -from the beginning of the file
853 import sys
854 +sys.exit(0)
855
856 "};
857 assert!(!is_editable_region_correct(patch));
858
859 let patch = indoc! {"
860 @@ -1,1 +1,1 @@
861 "};
862 assert!(is_editable_region_correct(patch));
863 }
864
865 #[test]
866 fn test_isolated_whitespace_purely_whitespace_patch() {
867 let patch = indoc! {"
868 @@ -1,3 +1,4 @@
869 fn main() {
870 +
871 println!(\"hello\");
872 }
873 "};
874 assert!(has_isolated_whitespace_changes(patch, None));
875 }
876
877 #[test]
878 fn test_isolated_whitespace_adjacent_to_real_change() {
879 let patch = indoc! {"
880 @@ -1,3 +1,4 @@
881 fn main() {
882 +
883 + let x = 1;
884 println!(\"hello\");
885 }
886 "};
887 assert!(!has_isolated_whitespace_changes(patch, None));
888 }
889
890 #[test]
891 fn test_isolated_whitespace_no_whitespace_changes() {
892 let patch = indoc! {"
893 @@ -1,3 +1,3 @@
894 fn main() {
895 - println!(\"hello\");
896 + println!(\"world\");
897 }
898 "};
899 assert!(!has_isolated_whitespace_changes(patch, None));
900 }
901
902 #[test]
903 fn test_isolated_whitespace_deletion() {
904 let patch = indoc! {"
905 @@ -1,4 +1,3 @@
906 fn main() {
907 -
908 println!(\"hello\");
909 }
910 "};
911 assert!(has_isolated_whitespace_changes(patch, None));
912 }
913
914 #[test]
915 fn test_isolated_whitespace_mixed_groups() {
916 let patch = indoc! {"
917 @@ -1,7 +1,8 @@
918 fn main() {
919 +
920 let x = 1;
921 - let y = 2;
922 + let y = 3;
923
924 +
925 println!(\"hello\");
926 }
927 "};
928 assert!(has_isolated_whitespace_changes(patch, None));
929 }
930
931 #[test]
932 fn test_isolated_whitespace_empty_patch() {
933 let patch = "";
934 assert!(!has_isolated_whitespace_changes(patch, None));
935 }
936
937 #[test]
938 fn test_isolated_whitespace_skipped_on_cursor_line() {
939 // The addition of a blank line at new-file line 2 should be skipped
940 // because the cursor is on that line.
941 let patch = indoc! {"
942 @@ -1,3 +1,4 @@
943 fn main() {
944 +
945 println!(\"hello\");
946 }
947 "};
948 // New-file line 2 is the added blank line
949 let cursor = cursor_on_line(2);
950 assert!(!has_isolated_whitespace_changes(patch, Some(&cursor)));
951 }
952
953 #[test]
954 fn test_isolated_whitespace_not_skipped_when_cursor_on_different_line() {
955 // The blank line is at new-file line 2, but the cursor is on line 1.
956 let patch = indoc! {"
957 @@ -1,3 +1,4 @@
958 fn main() {
959 +
960 println!(\"hello\");
961 }
962 "};
963 let cursor = cursor_on_line(1);
964 assert!(has_isolated_whitespace_changes(patch, Some(&cursor)));
965 }
966
967 #[test]
968 fn test_isolated_whitespace_deletion_not_skipped_by_cursor() {
969 // Deletions don't have a new-file line, so cursor can't suppress them.
970 let patch = indoc! {"
971 @@ -1,4 +1,3 @@
972 fn main() {
973 -
974 println!(\"hello\");
975 }
976 "};
977 let cursor = cursor_on_line(2);
978 assert!(has_isolated_whitespace_changes(patch, Some(&cursor)));
979 }
980}