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