1use std::collections::{BTreeMap, BTreeSet, HashSet};
5
6use objects::object::{ChangeImportance, SemanticChange};
7
8use super::analysis_similarity::{SimilarityMethod, compute_similarity_with_language};
9use crate::parser::{FunctionDef, Language, ParsedFile};
10
11const FUNCTION_RENAME_SIMILARITY_THRESHOLD: f64 = 0.58;
12const FUNCTION_RENAME_CANDIDATE_THRESHOLD: f64 = 0.30;
13const FUNCTION_RENAME_CONFIDENCE_MARGIN: f64 = 0.05;
14
15#[derive(Clone, Debug, PartialEq)]
18pub struct FunctionRenameCandidate {
19 pub new_name: String,
20 pub confidence: f64,
21 pub body_changed: bool,
23}
24
25#[derive(Clone, Debug, PartialEq)]
31pub enum FunctionRenameResolution {
32 Renamed(FunctionRenameCandidate),
33 Ambiguous(Vec<FunctionRenameCandidate>),
34 NotRenamed,
35}
36
37type FunctionMap = BTreeMap<String, Vec<FunctionDef>>;
55
56type InstanceRef = (String, usize);
59
60fn build_function_map(parsed: Option<&ParsedFile>) -> FunctionMap {
61 let Some(parsed) = parsed else {
62 return BTreeMap::new();
63 };
64 let mut map = FunctionMap::new();
65 for func in parsed.extract_functions() {
66 map.entry(func.name.clone()).or_default().push(func);
67 }
68 map
69}
70
71fn pair_within_name(
74 olds: &[FunctionDef],
75 news: &[FunctionDef],
76 similarity_method: SimilarityMethod,
77 language: Language,
78) -> (Vec<(usize, usize)>, Vec<usize>, Vec<usize>) {
79 let mut candidates: Vec<(usize, usize, f64)> = Vec::with_capacity(olds.len() * news.len());
80 for (i, o) in olds.iter().enumerate() {
81 for (j, n) in news.iter().enumerate() {
82 let similarity = if o.content == n.content {
83 1.0
84 } else {
85 compute_similarity_with_language(
86 &o.content,
87 &n.content,
88 similarity_method,
89 language,
90 )
91 };
92 candidates.push((i, j, similarity));
93 }
94 }
95 candidates
97 .sort_by(|(li, lj, ls), (ri, rj, rs)| rs.total_cmp(ls).then(li.cmp(ri)).then(lj.cmp(rj)));
98
99 let mut old_used = vec![false; olds.len()];
100 let mut new_used = vec![false; news.len()];
101 let mut pairs = Vec::new();
102 for (i, j, _) in candidates {
103 if !old_used[i] && !new_used[j] {
104 old_used[i] = true;
105 new_used[j] = true;
106 pairs.push((i, j));
107 }
108 }
109 let unmatched_old = old_used
110 .iter()
111 .enumerate()
112 .filter_map(|(i, used)| (!*used).then_some(i))
113 .collect();
114 let unmatched_new = new_used
115 .iter()
116 .enumerate()
117 .filter_map(|(j, used)| (!*used).then_some(j))
118 .collect();
119 (pairs, unmatched_old, unmatched_new)
120}
121
122pub fn detect_function_changes(
124 old_path: &std::path::Path,
125 new_path: &std::path::Path,
126 old_content: &str,
127 new_content: &str,
128 similarity_method: SimilarityMethod,
129) -> Vec<SemanticChange> {
130 let old_parsed = ParsedFile::parse(old_content, Language::from_path(old_path));
131 let new_parsed = ParsedFile::parse(new_content, Language::from_path(new_path));
132
133 detect_function_changes_with_parsed(
134 old_path,
135 new_path,
136 old_parsed.as_ref(),
137 new_parsed.as_ref(),
138 similarity_method,
139 )
140}
141
142pub fn resolve_function_rename(
148 path: &std::path::Path,
149 old_content: &str,
150 new_content: &str,
151 old_name: &str,
152 similarity_method: SimilarityMethod,
153) -> FunctionRenameResolution {
154 let language = Language::from_path(path);
155 let Some(old_parsed) = ParsedFile::parse(old_content, language) else {
156 return FunctionRenameResolution::NotRenamed;
157 };
158 let Some(new_parsed) = ParsedFile::parse(new_content, language) else {
159 return FunctionRenameResolution::NotRenamed;
160 };
161 let old_functions = old_parsed.extract_functions();
162 let new_functions = new_parsed.extract_functions();
163 let qualified = old_name.contains("::");
164 let old_matches = old_functions
165 .iter()
166 .filter(|function| {
167 if qualified {
168 function.qualified_name() == old_name
169 } else {
170 function.name == old_name
171 }
172 })
173 .collect::<Vec<_>>();
174 let [old_function] = old_matches.as_slice() else {
175 return if old_matches.is_empty() {
176 FunctionRenameResolution::NotRenamed
177 } else {
178 FunctionRenameResolution::Ambiguous(Vec::new())
179 };
180 };
181
182 let old_identities = old_functions
183 .iter()
184 .map(FunctionDef::qualified_name)
185 .collect::<BTreeSet<_>>();
186 let old_normalized =
187 normalized_function_for_matching(&old_function.content, &old_function.name);
188 let mut candidates = new_functions
189 .iter()
190 .filter(|function| function.container == old_function.container)
191 .filter(|function| function.name != old_function.name)
192 .filter(|function| !old_identities.contains(&function.qualified_name()))
193 .filter_map(|function| {
194 let new_normalized =
195 normalized_function_for_matching(&function.content, &function.name);
196 let confidence = compute_similarity_with_language(
197 &old_normalized,
198 &new_normalized,
199 similarity_method,
200 language,
201 );
202 (confidence >= FUNCTION_RENAME_CANDIDATE_THRESHOLD).then(|| FunctionRenameCandidate {
203 new_name: if qualified {
204 function.qualified_name()
205 } else {
206 function.name.clone()
207 },
208 confidence,
209 body_changed: renamed_function_content(&old_function.content, &old_function.name)
210 != renamed_function_content(&function.content, &function.name),
211 })
212 })
213 .collect::<Vec<_>>();
214 candidates.sort_by(|left, right| {
215 right
216 .confidence
217 .total_cmp(&left.confidence)
218 .then_with(|| left.new_name.cmp(&right.new_name))
219 });
220
221 let Some(best) = candidates.first() else {
222 return FunctionRenameResolution::NotRenamed;
223 };
224 let clear_lead = candidates.get(1).is_none_or(|runner_up| {
225 best.confidence - runner_up.confidence >= FUNCTION_RENAME_CONFIDENCE_MARGIN
226 });
227 if best.confidence >= FUNCTION_RENAME_SIMILARITY_THRESHOLD && clear_lead {
228 FunctionRenameResolution::Renamed(best.clone())
229 } else {
230 FunctionRenameResolution::Ambiguous(candidates)
231 }
232}
233
234pub(crate) fn detect_function_changes_with_parsed(
235 old_path: &std::path::Path,
236 new_path: &std::path::Path,
237 old_parsed: Option<&ParsedFile>,
238 new_parsed: Option<&ParsedFile>,
239 similarity_method: SimilarityMethod,
240) -> Vec<SemanticChange> {
241 let mut changes = Vec::new();
242 let mut file_modified = false;
243
244 let old_funcs = build_function_map(old_parsed);
245 let new_funcs = build_function_map(new_parsed);
246 let language = Language::from_path(new_path);
247
248 let mut pairs: Vec<(String, usize, usize)> = Vec::new();
250 let mut unmatched_old: Vec<InstanceRef> = Vec::new();
251 let mut unmatched_new: Vec<InstanceRef> = Vec::new();
252
253 let mut all_names: BTreeSet<&str> = BTreeSet::new();
254 all_names.extend(old_funcs.keys().map(String::as_str));
255 all_names.extend(new_funcs.keys().map(String::as_str));
256
257 let empty: Vec<FunctionDef> = Vec::new();
258 for name in &all_names {
259 let olds = old_funcs.get(*name).unwrap_or(&empty);
260 let news = new_funcs.get(*name).unwrap_or(&empty);
261 let (within, u_old, u_new) = pair_within_name(olds, news, similarity_method, language);
262 for (oi, ni) in within {
263 pairs.push(((*name).to_string(), oi, ni));
264 }
265 unmatched_old.extend(u_old.into_iter().map(|i| ((*name).to_string(), i)));
266 unmatched_new.extend(u_new.into_iter().map(|i| ((*name).to_string(), i)));
267 }
268 pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(&b.2)));
269
270 let moved_function_names = stable_order_moved_names(&old_funcs, &new_funcs, &pairs);
271
272 let mut consumed_old: HashSet<InstanceRef> = HashSet::new();
274 for (new_name, ni) in &unmatched_new {
275 let new_func = &new_funcs[new_name][*ni];
276 let renamed_from = unmatched_old
277 .iter()
278 .filter(|(on, oi)| !consumed_old.contains(&(on.clone(), *oi)))
279 .filter(|(on, _)| on != new_name)
280 .filter_map(|(on, oi)| {
281 let old_func = &old_funcs[on][*oi];
282 let similarity = compute_similarity_with_language(
283 &normalized_function_for_matching(&old_func.content, on),
284 &normalized_function_for_matching(&new_func.content, new_name),
285 similarity_method,
286 language,
287 );
288 let same_location_update = old_path == new_path
289 && old_func.start_line.abs_diff(new_func.start_line) <= 5
290 && similarity >= 0.30;
291 (similarity >= FUNCTION_RENAME_SIMILARITY_THRESHOLD || same_location_update)
292 .then_some(((on.clone(), *oi), similarity))
293 })
294 .max_by(
295 |(left_key, left_similarity), (right_key, right_similarity)| {
296 left_similarity
297 .total_cmp(right_similarity)
298 .then_with(|| right_key.cmp(left_key))
299 },
300 )
301 .map(|(key, _)| key);
302
303 if let Some((old_name, old_idx)) = renamed_from {
304 consumed_old.insert((old_name.clone(), old_idx));
305 changes.push(SemanticChange::FunctionRenamed {
306 file: new_path.to_path_buf(),
307 old_name,
308 new_name: new_name.clone(),
309 importance: Some(ChangeImportance::Low),
310 });
311 file_modified = true;
312 } else {
313 let source = extraction_source(&old_funcs, new_func);
314 if let Some(source_name) = source {
315 changes.push(SemanticChange::FunctionExtracted {
316 file: new_path.to_path_buf(),
317 name: new_name.clone(),
318 source_file: Some(old_path.to_path_buf()),
319 source_name: Some(source_name),
320 importance: Some(ChangeImportance::High),
321 });
322 } else {
323 changes.push(SemanticChange::FunctionAdded {
324 file: new_path.to_path_buf(),
325 name: new_name.clone(),
326 importance: Some(ChangeImportance::High),
327 });
328 }
329 file_modified = true;
330 }
331 }
332
333 for (old_name, oi) in &unmatched_old {
335 if consumed_old.contains(&(old_name.clone(), *oi)) {
336 continue;
337 }
338 changes.push(SemanticChange::FunctionDeleted {
339 file: new_path.to_path_buf(),
340 name: old_name.clone(),
341 importance: Some(ChangeImportance::High),
342 });
343 file_modified = true;
344 }
345
346 for (name, oi, ni) in &pairs {
348 let old_func = &old_funcs[name][*oi];
349 let new_func = &new_funcs[name][*ni];
350 if old_func.signature != new_func.signature {
351 changes.push(SemanticChange::SignatureChanged {
352 file: new_path.to_path_buf(),
353 name: name.clone(),
354 old_signature: old_func.signature.clone(),
355 new_signature: new_func.signature.clone(),
356 importance: Some(ChangeImportance::Medium),
357 });
358 file_modified = true;
359 } else if old_path == new_path
360 && old_func.content == new_func.content
361 && old_func.start_line != new_func.start_line
362 && moved_function_names.contains(name)
363 {
364 changes.push(SemanticChange::FunctionMoved {
365 file: new_path.to_path_buf(),
366 name: name.clone(),
367 old_start_line: old_func.start_line,
368 new_start_line: new_func.start_line,
369 importance: Some(ChangeImportance::Low),
370 });
371 file_modified = true;
372 } else if old_func.content != new_func.content {
373 changes.push(SemanticChange::FunctionModified {
374 file: new_path.to_path_buf(),
375 name: name.clone(),
376 importance: Some(ChangeImportance::Medium),
377 });
378 file_modified = true;
379 }
380 }
381
382 if file_modified {
383 changes.push(SemanticChange::FileModified {
384 path: new_path.to_path_buf(),
385 classification: None,
386 importance: None,
387 confidence: None,
388 });
389 }
390
391 changes
392}
393
394fn extraction_source(old_funcs: &FunctionMap, extracted: &FunctionDef) -> Option<String> {
395 let extracted_lines = meaningful_body_lines(&extracted.content);
396 if extracted_lines.is_empty() {
397 return None;
398 }
399
400 old_funcs
401 .iter()
402 .flat_map(|(name, funcs)| funcs.iter().map(move |func| (name.clone(), func)))
403 .filter_map(|(name, old_func)| {
404 let old_lines = meaningful_body_lines(&old_func.content);
405 let evidence = extraction_evidence(&old_lines, &extracted_lines);
406 evidence.is_strong().then_some((name, evidence))
407 })
408 .max_by(|left, right| {
409 left.1
410 .score
411 .total_cmp(&right.1.score)
412 .then_with(|| left.1.matched.cmp(&right.1.matched))
413 .then_with(|| right.0.cmp(&left.0))
414 })
415 .map(|(name, _)| name)
416}
417
418#[derive(Debug)]
419struct ExtractionEvidence {
420 matched: usize,
421 score: f64,
422 exact_matches: usize,
423 longest_exact_expression_len: usize,
424 extracted_lines: usize,
425}
426
427impl ExtractionEvidence {
428 fn is_strong(&self) -> bool {
429 if self.extracted_lines == 0 {
430 return false;
431 }
432
433 let coverage = self.matched as f64 / self.extracted_lines as f64;
434 let weighted_coverage = self.score / self.extracted_lines as f64;
435
436 if self.extracted_lines == 1 {
437 return self.exact_matches == 1
438 && weighted_coverage >= 0.95
439 && self.longest_exact_expression_len >= 20;
440 }
441
442 coverage >= 0.70 && weighted_coverage >= 0.70
443 }
444}
445
446fn extraction_evidence(old_lines: &[String], extracted_lines: &[String]) -> ExtractionEvidence {
447 let mut matched = 0;
448 let mut score = 0.0;
449 let mut exact_matches = 0;
450 let mut longest_exact_expression_len = 0;
451
452 for line in extracted_lines {
453 let best = old_lines
454 .iter()
455 .map(|old_line| body_line_match(old_line, line))
456 .max_by(|left, right| left.score.total_cmp(&right.score))
457 .unwrap_or_default();
458 if best.score > 0.0 {
459 matched += 1;
460 score += best.score;
461 }
462 if best.score >= 1.0 {
463 exact_matches += 1;
464 longest_exact_expression_len = longest_exact_expression_len.max(best.expression_len);
465 }
466 }
467
468 ExtractionEvidence {
469 matched,
470 score,
471 exact_matches,
472 longest_exact_expression_len,
473 extracted_lines: extracted_lines.len(),
474 }
475}
476
477#[derive(Clone, Copy, Debug, Default)]
478struct BodyLineMatch {
479 score: f64,
480 expression_len: usize,
481}
482
483fn body_line_match(old_line: &str, extracted_line: &str) -> BodyLineMatch {
484 let old = comparable_body_expression(old_line);
485 let extracted = comparable_body_expression(extracted_line);
486 if old == extracted {
487 return BodyLineMatch {
488 score: 1.0,
489 expression_len: extracted.len(),
490 };
491 }
492 if extracted.len() >= 24 && old.contains(&extracted) {
493 return BodyLineMatch {
494 score: 0.75,
495 expression_len: extracted.len(),
496 };
497 }
498 if old.len() >= 24 && extracted.contains(&old) {
499 return BodyLineMatch {
500 score: 0.75,
501 expression_len: old.len(),
502 };
503 }
504 BodyLineMatch::default()
505}
506
507fn comparable_body_expression(line: &str) -> String {
508 let trimmed = line
509 .trim()
510 .trim_end_matches(';')
511 .trim_start_matches("return ")
512 .trim();
513 let expression = trimmed
514 .split_once('=')
515 .map(|(_, rhs)| rhs.trim())
516 .unwrap_or(trimmed);
517 expression.trim_end_matches(';').trim().to_string()
518}
519
520fn meaningful_body_lines(content: &str) -> Vec<String> {
521 content
522 .lines()
523 .map(str::trim)
524 .filter(|line| {
525 !line.is_empty()
526 && *line != "{"
527 && *line != "}"
528 && !line.starts_with("fn ")
529 && !line.starts_with("pub fn ")
530 && !line.starts_with("async fn ")
531 && !line.starts_with("pub async fn ")
532 })
533 .map(ToString::to_string)
534 .collect()
535}
536
537fn stable_order_moved_names(
538 old_funcs: &FunctionMap,
539 new_funcs: &FunctionMap,
540 pairs: &[(String, usize, usize)],
541) -> HashSet<String> {
542 let mut old_order: Vec<(usize, String)> = Vec::new();
543 let mut new_order: Vec<(usize, String)> = Vec::new();
544 for (name, oi, ni) in pairs {
545 let old_func = &old_funcs[name][*oi];
546 let new_func = &new_funcs[name][*ni];
547 if old_func.content == new_func.content {
548 old_order.push((old_func.start_line, name.clone()));
549 new_order.push((new_func.start_line, name.clone()));
550 }
551 }
552 old_order.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
553 new_order.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
554
555 let old_names: Vec<String> = old_order.into_iter().map(|(_, n)| n).collect();
556 let new_names: Vec<String> = new_order.into_iter().map(|(_, n)| n).collect();
557 if old_names == new_names {
558 return HashSet::new();
559 }
560 old_names
561 .into_iter()
562 .zip(new_names)
563 .filter_map(|(old_name, new_name)| (old_name != new_name).then_some([old_name, new_name]))
564 .flatten()
565 .collect()
566}
567
568fn normalized_function_for_matching(content: &str, name: &str) -> String {
569 renamed_function_content(content, name)
570 .lines()
571 .map(str::trim)
572 .filter(|line| !line.is_empty())
573 .collect::<Vec<_>>()
574 .join("\n")
575}
576
577fn renamed_function_content(content: &str, name: &str) -> String {
578 content.replace(name, "__function_name__")
579}