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