Skip to main content

semantic/analysis/
analysis_functions.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Function-level semantic changes.
3
4use 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/// One structurally plausible target for a function that disappeared under
16/// its old name.
17#[derive(Clone, Debug, PartialEq)]
18pub struct FunctionRenameCandidate {
19    pub new_name: String,
20    pub confidence: f64,
21    /// Whether bytes other than the consistently renamed function name changed.
22    pub body_changed: bool,
23}
24
25/// Conservative result for resolving one function rename.
26///
27/// The general semantic diff may choose a stable best pairing so it can
28/// describe a whole file. Durable anchors have a stricter contract: only a
29/// high-confidence candidate with a clear lead may move the anchor.
30#[derive(Clone, Debug, PartialEq)]
31pub enum FunctionRenameResolution {
32    Renamed(FunctionRenameCandidate),
33    Ambiguous(Vec<FunctionRenameCandidate>),
34    NotRenamed,
35}
36
37/// Multi-map: function name → all definitions on this side with that
38/// name, in source order.
39///
40/// `BTreeMap<String, FunctionDef>` would silently collapse same-name
41/// redeclarations (JS allows two `function foo()` at module scope;
42/// Python allows repeated top-level `def foo()`). A prior fix (r1)
43/// keyed entries by `(name, occurrence)` to stop the collapse, but
44/// that paired old's `foo[0]` with new's `foo[0]` regardless of body
45/// content — so a fresh same-name definition inserted before existing
46/// ones produced a bogus FunctionModified with the wrong delta plus a
47/// misclassified add/delete (Codex cid 3259311747, heddle#125 r2).
48///
49/// Keeping a `Vec` per name lets us pair instances across versions by
50/// *content similarity* (see `pair_within_name`) rather than by
51/// within-side position. merge_driver keeps the positional keying
52/// (commit 2198b00) because its job is line-up merging within a
53/// logical slot; analysis_functions needs fuzzy cross-version identity.
54type FunctionMap = BTreeMap<String, Vec<FunctionDef>>;
55
56/// `(name, within-side index)` — identifies a single function instance
57/// on one side. Not a cross-version identity.
58type 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
71/// Greedy best-similarity matcher within a single name bucket. Returns
72/// (paired old/new indices, unpaired old indices, unpaired new indices).
73fn 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    // Highest similarity first; deterministic tiebreak: lowest old, then lowest new.
96    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
122/// Detect function-level changes between two file versions.
123pub 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
142/// Resolve the new name of one function within a single file.
143///
144/// Candidates come from parsed function definitions that are new under their
145/// qualified identity and share the original definition's container. This
146/// deliberately excludes text search and existing, unchanged neighbours.
147pub 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    // Phase 1: pair instances within each name bucket by content similarity.
249    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    // Phase 2: cross-name rename detection over leftovers.
273    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    // Phase 3: unconsumed unmatched_old → deletions.
334    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    // Phase 4: paired instances → signature / move / modified.
347    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}