Skip to main content

code_moniker_workspace/changes/semantic/
pairing.rs

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: &current_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: &current_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}