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