1use std::path::Path;
2
3use code_moniker_core::core::code_graph::{CodeGraph, DefRecord};
4use code_moniker_core::lang::Lang;
5use rustc_hash::FxHashMap;
6
7use crate::code::{def_kind, is_navigable_def, last_name};
8use crate::environment;
9
10use super::fingerprint::{
11 FingerprintScope, IdentityTail, def_fingerprints, identity_tail, split_callable_name,
12};
13use super::model::{ChangeFacets, Confidence, SemanticKind, SymbolChange, SymbolSide};
14
15pub struct FileSide<'a> {
16 pub lang: Lang,
17 pub graph: &'a CodeGraph,
18 pub source: &'a str,
19 pub file_path: &'a Path,
20}
21
22pub struct PairInputs<'a> {
23 pub base: FileSide<'a>,
24 pub current: FileSide<'a>,
25 pub file_moved: bool,
26}
27
28struct SideDef {
29 tail: IdentityTail,
30 base_name: Vec<u8>,
31 params: Option<Vec<u8>>,
32 has_body: bool,
33 text_hash: u64,
34 full_hash: u64,
35 side: SymbolSide,
36}
37
38struct PairingState {
39 old: Vec<Option<SideDef>>,
40 new: Vec<Option<SideDef>>,
41 changes: Vec<SymbolChange>,
42 file_moved: bool,
43}
44
45pub struct FilePairing {
46 changes: Vec<SymbolChange>,
47 unpaired_old: Vec<SideDef>,
48 unpaired_new: Vec<SideDef>,
49}
50
51pub fn pair_file(inputs: PairInputs<'_>) -> FilePairing {
52 let mut state = PairingState {
53 old: collect_side_defs(&inputs.base),
54 new: collect_side_defs(&inputs.current),
55 changes: Vec::new(),
56 file_moved: inputs.file_moved,
57 };
58 pair_exact_tails(&mut state);
59 let mut renames = pair_signature_changes(&mut state);
60 renames.extend(pair_renames(&mut state));
61 propagate_container_renames(&mut state, renames);
62 FilePairing {
63 unpaired_old: collapsed_roots(&mut state.old),
64 unpaired_new: collapsed_roots(&mut state.new),
65 changes: state.changes,
66 }
67}
68
69pub fn finish_files(pairings: Vec<FilePairing>) -> Vec<SymbolChange> {
70 let mut changes = Vec::new();
71 let mut old_pool = Vec::new();
72 let mut new_pool = Vec::new();
73 for pairing in pairings {
74 changes.extend(pairing.changes);
75 old_pool.extend(pairing.unpaired_old);
76 new_pool.extend(pairing.unpaired_new);
77 }
78 changes.extend(pair_across_containers(&mut old_pool, &mut new_pool));
79 changes.extend(
80 old_pool
81 .into_iter()
82 .map(|def| unpaired_change(def, SemanticKind::Removed)),
83 );
84 changes.extend(
85 new_pool
86 .into_iter()
87 .map(|def| unpaired_change(def, SemanticKind::Added)),
88 );
89 sort_changes(&mut changes);
90 changes
91}
92
93fn pair_across_containers(
94 old_pool: &mut Vec<SideDef>,
95 new_pool: &mut Vec<SideDef>,
96) -> Vec<SymbolChange> {
97 let mut old_slots: Vec<Option<SideDef>> = old_pool.drain(..).map(Some).collect();
98 let mut new_slots: Vec<Option<SideDef>> = new_pool.drain(..).map(Some).collect();
99 let mut groups: SlotGroups<(String, Vec<u8>, u64)> = FxHashMap::default();
100 collect_groups(&old_slots, &mut groups, 0, container_move_key);
101 collect_groups(&new_slots, &mut groups, 1, container_move_key);
102 let mut changes = Vec::new();
103 for (olds, news) in groups.into_values() {
104 let [old_idx] = olds.as_slice() else { continue };
105 let [new_idx] = news.as_slice() else { continue };
106 let old = old_slots[*old_idx].take().expect("grouped old");
107 let new = new_slots[*new_idx].take().expect("grouped new");
108 let facets = ChangeFacets {
109 file_moved: old.side.file_path != new.side.file_path,
110 ..ChangeFacets::default()
111 };
112 changes.push(paired_change(SemanticKind::Moved, facets, old, new));
113 }
114 old_pool.extend(old_slots.into_iter().flatten());
115 new_pool.extend(new_slots.into_iter().flatten());
116 changes
117}
118
119fn container_move_key(def: &SideDef) -> Option<(String, Vec<u8>, u64)> {
120 if !def.has_body {
121 return None;
122 }
123 Some((def.side.kind.clone(), def.base_name.clone(), def.full_hash))
124}
125
126fn collect_side_defs(file: &FileSide<'_>) -> Vec<Option<SideDef>> {
127 let nested_spans: Vec<(u32, u32)> = file
128 .graph
129 .defs()
130 .filter(|def| is_navigable_def(file.lang, def))
131 .filter_map(|def| def.position)
132 .collect();
133 file.graph
134 .defs()
135 .filter(|def| is_navigable_def(file.lang, def))
136 .filter_map(|def| side_def(file, def, &nested_spans))
137 .map(Some)
138 .collect()
139}
140
141fn side_def(file: &FileSide<'_>, def: &DefRecord, nested_spans: &[(u32, u32)]) -> Option<SideDef> {
142 let tail = identity_tail(&def.moniker, file.graph.root())?;
143 let last = tail.last()?;
144 let (base_name, params) = split_callable_name(&last.name);
145 let base_name = base_name.to_vec();
146 let params = params.map(<[u8]>::to_vec);
147 let prints = def
148 .position
149 .map(|span| {
150 def_fingerprints(FingerprintScope {
151 source: file.source,
152 span,
153 name: &base_name,
154 nested_spans,
155 })
156 })
157 .unwrap_or_default();
158 let side = SymbolSide {
159 moniker: def.moniker.clone(),
160 file_path: file.file_path.to_path_buf(),
161 kind: def_kind(def),
162 name: last_name(&def.moniker),
163 visibility: String::from_utf8_lossy(&def.visibility).into_owned(),
164 signature: String::from_utf8_lossy(&def.signature).into_owned(),
165 line_range: def
166 .position
167 .map(|(start, end)| environment::line_range(file.source, start, end)),
168 body_hash: prints.body,
169 };
170 Some(SideDef {
171 tail,
172 base_name,
173 params,
174 has_body: def.position.is_some(),
175 text_hash: prints.text,
176 full_hash: prints.full,
177 side,
178 })
179}
180
181fn pair_exact_tails(state: &mut PairingState) {
182 let mut by_tail: FxHashMap<IdentityTail, usize> = FxHashMap::default();
183 for (idx, slot) in state.new.iter().enumerate() {
184 if let Some(def) = slot {
185 by_tail.insert(def.tail.clone(), idx);
186 }
187 }
188 for old_slot in &mut state.old {
189 let Some(new_idx) = old_slot.as_ref().and_then(|def| by_tail.remove(&def.tail)) else {
190 continue;
191 };
192 let old = old_slot.take().expect("slot checked above");
193 let new = state.new[new_idx].take().expect("indexed above");
194 if let Some(change) = classify_matched(old, new, state.file_moved) {
195 state.changes.push(change);
196 }
197 }
198}
199
200fn classify_matched(old: SideDef, new: SideDef, file_moved: bool) -> Option<SymbolChange> {
201 let comparable = old.has_body && new.has_body;
202 let body_changed = comparable && old.side.body_hash != new.side.body_hash;
203 let text_changed = comparable && old.text_hash != new.text_hash;
204 let facets = ChangeFacets {
205 body_changed,
206 signature_changed: old.side.signature != new.side.signature || old.params != new.params,
207 visibility_changed: old.side.visibility != new.side.visibility,
208 header_changed: text_changed && !body_changed,
209 file_moved,
210 };
211 if !facets.any() {
212 return None;
213 }
214 let kind = if file_moved {
215 SemanticKind::Moved
216 } else if facets.body_changed {
217 SemanticKind::BodyModified
218 } else if facets.signature_changed {
219 SemanticKind::SignatureChanged
220 } else {
221 SemanticKind::AttributeChanged
222 };
223 Some(paired_change(kind, facets, old, new))
224}
225
226fn paired_change(
227 kind: SemanticKind,
228 facets: ChangeFacets,
229 old: SideDef,
230 new: SideDef,
231) -> SymbolChange {
232 SymbolChange {
233 kind,
234 confidence: Confidence::Certain,
235 facets,
236 old: Some(old.side),
237 new: Some(new.side),
238 }
239}
240
241type RenameMap = Vec<(IdentityTail, IdentityTail)>;
242type SlotGroups<K> = FxHashMap<K, (Vec<usize>, Vec<usize>)>;
243
244fn pair_signature_changes(state: &mut PairingState) -> RenameMap {
245 let mut groups: SlotGroups<(IdentityTail, String, Vec<u8>)> = FxHashMap::default();
246 collect_groups(&state.old, &mut groups, 0, signature_key);
247 collect_groups(&state.new, &mut groups, 1, signature_key);
248 let mut retargets = Vec::new();
249 for (olds, news) in groups.into_values() {
250 let [old_idx] = olds.as_slice() else { continue };
251 let [new_idx] = news.as_slice() else { continue };
252 let old = state.old[*old_idx].take().expect("grouped old");
253 let new = state.new[*new_idx].take().expect("grouped new");
254 retargets.push((old.tail.clone(), new.tail.clone()));
255 let facets = ChangeFacets {
256 signature_changed: true,
257 visibility_changed: old.side.visibility != new.side.visibility,
258 file_moved: state.file_moved,
259 ..ChangeFacets::default()
260 };
261 state.changes.push(paired_change(
262 SemanticKind::SignatureChanged,
263 facets,
264 old,
265 new,
266 ));
267 }
268 retargets
269}
270
271fn signature_key(def: &SideDef) -> Option<(IdentityTail, String, Vec<u8>)> {
272 def.params.as_ref()?;
273 Some((
274 def.tail.parent(),
275 def.side.kind.clone(),
276 def.base_name.clone(),
277 ))
278}
279
280fn pair_renames(state: &mut PairingState) -> RenameMap {
281 let mut groups: SlotGroups<(IdentityTail, String, u64)> = FxHashMap::default();
282 collect_groups(&state.old, &mut groups, 0, rename_key);
283 collect_groups(&state.new, &mut groups, 1, rename_key);
284 let mut retargets = Vec::new();
285 for (olds, news) in groups.into_values() {
286 let [old_idx] = olds.as_slice() else { continue };
287 let [new_idx] = news.as_slice() else { continue };
288 let differs = state.old[*old_idx]
289 .as_ref()
290 .zip(state.new[*new_idx].as_ref())
291 .is_some_and(|(old, new)| old.base_name != new.base_name);
292 if !differs {
293 continue;
294 }
295 let old = state.old[*old_idx].take().expect("grouped old");
296 let new = state.new[*new_idx].take().expect("grouped new");
297 retargets.push((old.tail.clone(), new.tail.clone()));
298 let facets = ChangeFacets {
299 signature_changed: old.params != new.params,
300 visibility_changed: old.side.visibility != new.side.visibility,
301 file_moved: state.file_moved,
302 ..ChangeFacets::default()
303 };
304 state
305 .changes
306 .push(paired_change(SemanticKind::Renamed, facets, old, new));
307 }
308 retargets
309}
310
311fn rename_key(def: &SideDef) -> Option<(IdentityTail, String, u64)> {
312 if !def.has_body {
313 return None;
314 }
315 Some((def.tail.parent(), def.side.kind.clone(), def.text_hash))
316}
317
318fn collect_groups<K: std::hash::Hash + Eq>(
319 slots: &[Option<SideDef>],
320 groups: &mut SlotGroups<K>,
321 side: usize,
322 key: impl Fn(&SideDef) -> Option<K>,
323) {
324 for (idx, slot) in slots.iter().enumerate() {
325 let Some(group_key) = slot.as_ref().and_then(&key) else {
326 continue;
327 };
328 let entry = groups.entry(group_key).or_default();
329 if side == 0 {
330 entry.0.push(idx);
331 } else {
332 entry.1.push(idx);
333 }
334 }
335}
336
337fn propagate_container_renames(state: &mut PairingState, mut renames: RenameMap) {
338 while !renames.is_empty() {
339 if !rewrite_tails(&mut state.old, &renames) {
340 return;
341 }
342 pair_exact_tails(state);
343 let mut next = pair_signature_changes(state);
344 next.extend(pair_renames(state));
345 renames = next;
346 }
347}
348
349fn rewrite_tails(slots: &mut [Option<SideDef>], renames: &RenameMap) -> bool {
350 let mut rewrote = false;
351 for slot in slots {
352 let Some(def) = slot else { continue };
353 for (from, to) in renames {
354 let Some(tail) = def.tail.rewrite_prefix(from, to) else {
355 continue;
356 };
357 def.tail = tail;
358 rewrote = true;
359 break;
360 }
361 }
362 rewrote
363}
364
365fn collapsed_roots(slots: &mut [Option<SideDef>]) -> Vec<SideDef> {
366 let defs: Vec<SideDef> = slots.iter_mut().filter_map(Option::take).collect();
367 let tails: Vec<IdentityTail> = defs.iter().map(|def| def.tail.clone()).collect();
368 defs.into_iter()
369 .filter(|def| {
370 !tails
371 .iter()
372 .any(|tail| tail != &def.tail && def.tail.starts_with(tail))
373 })
374 .collect()
375}
376
377fn unpaired_change(def: SideDef, kind: SemanticKind) -> SymbolChange {
378 let (old, new) = match kind {
379 SemanticKind::Removed => (Some(def.side), None),
380 _ => (None, Some(def.side)),
381 };
382 SymbolChange {
383 kind,
384 confidence: Confidence::Certain,
385 facets: ChangeFacets::default(),
386 old,
387 new,
388 }
389}
390
391fn sort_changes(changes: &mut [SymbolChange]) {
392 changes.sort_by(|a, b| change_order(a).cmp(&change_order(b)));
393}
394
395fn change_order(change: &SymbolChange) -> (&Path, u32, &str) {
396 let side = change
397 .new
398 .as_ref()
399 .or(change.old.as_ref())
400 .expect("a change has at least one side");
401 let line = side.line_range.map(|(start, _)| start).unwrap_or(u32::MAX);
402 (side.file_path.as_path(), line, side.name.as_str())
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use std::path::{Path, PathBuf};
409
410 fn pair_rust(base: &str, current: &str) -> Vec<SymbolChange> {
411 pair_lang(Lang::Rs, base, current, "src/lib.rs")
412 }
413
414 fn pair_lang(lang: Lang, base: &str, current: &str, rel: &str) -> Vec<SymbolChange> {
415 let base_graph = environment::extract_source(lang, base, Path::new(rel));
416 let current_graph = environment::extract_source(lang, current, Path::new(rel));
417 finish_files(vec![pair_file(PairInputs {
418 base: FileSide {
419 lang,
420 graph: &base_graph,
421 source: base,
422 file_path: Path::new(rel),
423 },
424 current: FileSide {
425 lang,
426 graph: ¤t_graph,
427 source: current,
428 file_path: Path::new(rel),
429 },
430 file_moved: false,
431 })])
432 }
433
434 fn pair_moved_rust(
435 base: &str,
436 old_rel: &str,
437 current: &str,
438 new_rel: &str,
439 ) -> Vec<SymbolChange> {
440 let base_graph = environment::extract_source(Lang::Rs, base, Path::new(old_rel));
441 let current_graph = environment::extract_source(Lang::Rs, current, Path::new(new_rel));
442 finish_files(vec![pair_file(PairInputs {
443 base: FileSide {
444 lang: Lang::Rs,
445 graph: &base_graph,
446 source: base,
447 file_path: Path::new(old_rel),
448 },
449 current: FileSide {
450 lang: Lang::Rs,
451 graph: ¤t_graph,
452 source: current,
453 file_path: Path::new(new_rel),
454 },
455 file_moved: true,
456 })])
457 }
458
459 struct EditedFile<'a> {
460 rel: &'a str,
461 base: &'a str,
462 current: &'a str,
463 }
464
465 fn pair_many_rust(files: &[EditedFile<'_>]) -> Vec<SymbolChange> {
466 let graphs: Vec<_> = files
467 .iter()
468 .map(|file| {
469 (
470 environment::extract_source(Lang::Rs, file.base, Path::new(file.rel)),
471 environment::extract_source(Lang::Rs, file.current, Path::new(file.rel)),
472 )
473 })
474 .collect();
475 let pairings = files
476 .iter()
477 .zip(&graphs)
478 .map(|(file, (base_graph, current_graph))| {
479 pair_file(PairInputs {
480 base: FileSide {
481 lang: Lang::Rs,
482 graph: base_graph,
483 source: file.base,
484 file_path: Path::new(file.rel),
485 },
486 current: FileSide {
487 lang: Lang::Rs,
488 graph: current_graph,
489 source: file.current,
490 file_path: Path::new(file.rel),
491 },
492 file_moved: false,
493 })
494 })
495 .collect();
496 finish_files(pairings)
497 }
498
499 fn kinds(changes: &[SymbolChange]) -> Vec<SemanticKind> {
500 changes.iter().map(|change| change.kind).collect()
501 }
502
503 #[test]
504 fn body_edit_reports_body_modified() {
505 let changes = pair_rust(
506 "fn kept() {}\nfn edited() { let x = 1; }\n",
507 "fn kept() {}\nfn edited() { let x = 2; }\n",
508 );
509
510 assert_eq!(kinds(&changes), vec![SemanticKind::BodyModified]);
511 let change = &changes[0];
512 assert!(change.facets.body_changed);
513 assert!(!change.facets.signature_changed);
514 assert_eq!(change.confidence, Confidence::Certain);
515 assert!(
516 change
517 .new
518 .as_ref()
519 .is_some_and(|side| side.name.starts_with("edited")),
520 );
521 }
522
523 #[test]
524 fn param_addition_reports_signature_changed() {
525 let changes = pair_rust(
526 "fn grow(a: u32) -> u32 { a }\n",
527 "fn grow(a: u32, b: u32) -> u32 { a + b }\n",
528 );
529
530 assert_eq!(kinds(&changes), vec![SemanticKind::SignatureChanged]);
531 let change = &changes[0];
532 assert!(change.facets.signature_changed);
533 assert!(!change.facets.body_changed);
534 assert_eq!(change.confidence, Confidence::Certain);
535 assert!(change.old.is_some() && change.new.is_some());
536 }
537
538 #[test]
539 fn pure_rename_pairs_old_and_new_names() {
540 let changes = pair_rust(
541 "fn old_name(n: u32) -> u32 { old_name(n) }\nfn stay() {}\n",
542 "fn fresh_name(n: u32) -> u32 { fresh_name(n) }\nfn stay() {}\n",
543 );
544
545 assert_eq!(kinds(&changes), vec![SemanticKind::Renamed]);
546 let change = &changes[0];
547 assert_eq!(change.confidence, Confidence::Certain);
548 assert!(
549 change
550 .old
551 .as_ref()
552 .is_some_and(|side| side.name.starts_with("old_name"))
553 );
554 assert!(
555 change
556 .new
557 .as_ref()
558 .is_some_and(|side| side.name.starts_with("fresh_name"))
559 );
560 }
561
562 #[test]
563 fn rename_with_body_edit_stays_removed_plus_added() {
564 let changes = pair_rust(
565 "fn old_name() { let x = 1; }\n",
566 "fn fresh_name() { let x = 2; }\n",
567 );
568
569 let mut sorted = kinds(&changes);
570 sorted.sort_by_key(|kind| kind.label());
571 assert_eq!(sorted, vec![SemanticKind::Added, SemanticKind::Removed]);
572 }
573
574 #[test]
575 fn visibility_only_change_reports_attribute_changed() {
576 let changes = pair_rust("fn open() { work(); }\n", "pub fn open() { work(); }\n");
577
578 assert_eq!(kinds(&changes), vec![SemanticKind::AttributeChanged]);
579 let change = &changes[0];
580 assert!(change.facets.visibility_changed);
581 assert!(!change.facets.body_changed);
582 }
583
584 #[test]
585 fn ambiguous_duplicate_bodies_stay_removed_plus_added() {
586 let changes = pair_rust(
587 "fn twin_a() { work(); }\nfn twin_b() { work(); }\n",
588 "fn twin_c() { work(); }\nfn twin_d() { work(); }\n",
589 );
590
591 let mut sorted = kinds(&changes);
592 sorted.sort_by_key(|kind| kind.label());
593 assert_eq!(
594 sorted,
595 vec![
596 SemanticKind::Added,
597 SemanticKind::Added,
598 SemanticKind::Removed,
599 SemanticKind::Removed
600 ]
601 );
602 }
603
604 #[test]
605 fn container_rename_subsumes_unchanged_children() {
606 let changes = pair_rust(
607 "struct Holder;\nimpl Holder {\n\tfn touch(&self) { work(); }\n\tfn poke(&self) {}\n}\n",
608 "struct Keeper;\nimpl Keeper {\n\tfn touch(&self) { work(); }\n\tfn poke(&self) {}\n}\n",
609 );
610
611 assert_eq!(
612 kinds(&changes),
613 vec![SemanticKind::Renamed],
614 "children unchanged under a container rename must not surface: {changes:?}"
615 );
616 assert!(
617 changes[0]
618 .new
619 .as_ref()
620 .is_some_and(|side| side.name == "Keeper")
621 );
622 }
623
624 #[test]
625 fn container_rename_still_reports_edited_children() {
626 let changes = pair_rust(
627 "struct Holder;\nimpl Holder {\n\tfn touch(&self) { work(); }\n}\n",
628 "struct Keeper;\nimpl Keeper {\n\tfn touch(&self) { rest(); }\n}\n",
629 );
630
631 let labels = kinds(&changes);
632 assert!(labels.contains(&SemanticKind::Renamed), "{changes:?}");
633 assert!(labels.contains(&SemanticKind::BodyModified), "{changes:?}");
634 assert_eq!(labels.len(), 2, "{changes:?}");
635 }
636
637 #[test]
638 fn added_subtree_collapses_to_its_root() {
639 let changes = pair_rust(
640 "fn kept() {}\n",
641 "fn kept() {}\nstruct Fresh {\n\tcount: u32,\n}\nimpl Fresh {\n\tfn build() {}\n}\n",
642 );
643
644 let added: Vec<_> = changes
645 .iter()
646 .filter(|change| change.kind == SemanticKind::Added)
647 .collect();
648 assert_eq!(added.len(), 1, "{changes:?}");
649 assert!(
650 added[0]
651 .new
652 .as_ref()
653 .is_some_and(|side| side.name == "Fresh")
654 );
655 }
656
657 #[test]
658 fn pure_file_move_reports_only_moved_facts() {
659 let source = "fn alpha() { work(); }\nfn beta() { rest(); }\n";
660 let changes = pair_moved_rust(source, "src/old_spot.rs", source, "src/new_spot.rs");
661
662 assert_eq!(
663 kinds(&changes),
664 vec![SemanticKind::Moved, SemanticKind::Moved],
665 "{changes:?}"
666 );
667 assert!(changes.iter().all(|change| {
668 change.facets.file_moved
669 && !change.facets.body_changed
670 && change.confidence == Confidence::Certain
671 }));
672 }
673
674 #[test]
675 fn file_move_with_one_edit_isolates_the_edited_symbol() {
676 let changes = pair_moved_rust(
677 "fn alpha() { work(); }\nfn beta() { rest(); }\n",
678 "src/old_spot.rs",
679 "fn alpha() { work(); }\nfn beta() { rest(); rest(); }\n",
680 "src/new_spot.rs",
681 );
682
683 let edited: Vec<_> = changes
684 .iter()
685 .filter(|change| change.facets.body_changed)
686 .collect();
687 assert_eq!(edited.len(), 1, "{changes:?}");
688 assert_eq!(edited[0].kind, SemanticKind::Moved);
689 assert!(
690 edited[0]
691 .new
692 .as_ref()
693 .is_some_and(|side| side.name.starts_with("beta"))
694 );
695 assert!(changes.iter().all(|change| change.facets.file_moved));
696 }
697
698 #[test]
699 fn moved_file_pairs_identical_twins_by_tail() {
700 let source = "fn twin_a() { work(); }\nfn twin_b() { work(); }\n";
701 let changes = pair_moved_rust(source, "src/old_spot.rs", source, "src/new_spot.rs");
702
703 assert_eq!(
704 kinds(&changes),
705 vec![SemanticKind::Moved, SemanticKind::Moved],
706 "{changes:?}"
707 );
708 }
709
710 #[test]
711 fn cross_file_extraction_pairs_as_moved() {
712 let changes = pair_many_rust(&[
713 EditedFile {
714 rel: "src/a.rs",
715 base: "fn stay() {}\nfn traveler(x: u32) -> u32 { x + 1 }\n",
716 current: "fn stay() {}\n",
717 },
718 EditedFile {
719 rel: "src/b.rs",
720 base: "fn other() {}\n",
721 current: "fn other() {}\nfn traveler(x: u32) -> u32 { x + 1 }\n",
722 },
723 ]);
724
725 assert_eq!(kinds(&changes), vec![SemanticKind::Moved], "{changes:?}");
726 let change = &changes[0];
727 assert!(change.facets.file_moved);
728 assert_eq!(
729 change.old.as_ref().map(|side| side.file_path.clone()),
730 Some(PathBuf::from("src/a.rs"))
731 );
732 assert_eq!(
733 change.new.as_ref().map(|side| side.file_path.clone()),
734 Some(PathBuf::from("src/b.rs"))
735 );
736 }
737
738 #[test]
739 fn cross_file_twins_with_the_same_name_pair_one_to_one() {
740 let changes = pair_many_rust(&[
741 EditedFile {
742 rel: "src/a.rs",
743 base: "fn twin_a() { work(); }\nfn twin_b() { work(); }\n",
744 current: "",
745 },
746 EditedFile {
747 rel: "src/b.rs",
748 base: "",
749 current: "fn twin_a() { work(); }\nfn twin_b() { work(); }\n",
750 },
751 ]);
752
753 assert_eq!(
754 kinds(&changes),
755 vec![SemanticKind::Moved, SemanticKind::Moved],
756 "same-name twins map unambiguously across files: {changes:?}"
757 );
758 }
759
760 #[test]
761 fn ambiguous_cross_file_destinations_stay_removed_plus_added() {
762 let changes = pair_many_rust(&[
763 EditedFile {
764 rel: "src/a.rs",
765 base: "fn helper() { work(); }\n",
766 current: "",
767 },
768 EditedFile {
769 rel: "src/b.rs",
770 base: "fn helper() { work(); }\n",
771 current: "",
772 },
773 EditedFile {
774 rel: "src/c.rs",
775 base: "",
776 current: "fn helper() { work(); }\n",
777 },
778 ]);
779
780 let mut sorted = kinds(&changes);
781 sorted.sort_by_key(|kind| kind.label());
782 assert_eq!(
783 sorted,
784 vec![
785 SemanticKind::Added,
786 SemanticKind::Removed,
787 SemanticKind::Removed
788 ],
789 "two possible origins must refuse to pair: {changes:?}"
790 );
791 }
792
793 #[test]
794 fn same_file_container_move_reports_moved_without_file_facet() {
795 let changes = pair_rust(
796 "struct Holder;\nstruct Keeper;\nimpl Holder {\n\tfn helper(&self) -> u32 { 2 }\n}\nimpl Keeper {}\n",
797 "struct Holder;\nstruct Keeper;\nimpl Holder {}\nimpl Keeper {\n\tfn helper(&self) -> u32 { 2 }\n}\n",
798 );
799
800 assert_eq!(kinds(&changes), vec![SemanticKind::Moved], "{changes:?}");
801 assert!(!changes[0].facets.file_moved);
802 }
803
804 #[test]
805 fn typescript_rename_pairs() {
806 let changes = pair_lang(
807 Lang::Ts,
808 "export function oldName(a: number): number { return oldName(a); }\n",
809 "export function freshName(a: number): number { return freshName(a); }\n",
810 "src/util.ts",
811 );
812
813 assert_eq!(kinds(&changes), vec![SemanticKind::Renamed], "{changes:?}");
814 }
815
816 #[test]
817 fn java_body_edit_reports_body_modified() {
818 let changes = pair_lang(
819 Lang::Java,
820 "class Service {\n\tint total() { return 1; }\n}\n",
821 "class Service {\n\tint total() { return 2; }\n}\n",
822 "src/Service.java",
823 );
824
825 assert_eq!(
826 kinds(&changes),
827 vec![SemanticKind::BodyModified],
828 "{changes:?}"
829 );
830 }
831}