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