Skip to main content

code_moniker_workspace/changes/semantic/
virtual_diff_impact.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use code_moniker_core::core::code_graph::CodeGraph;
5use code_moniker_core::lang::Lang;
6
7use crate::environment::{self, ExtractContext};
8
9use super::model::{HunkCoverage, RefChange, SymbolChange};
10use super::pairing::{FilePairing, FileSide, PairInputs, finish_files, pair_file};
11use super::refpairs::{CoverageInputs, RenameContext, hunk_coverage, pair_refs};
12use super::review::{FileFacts, SemanticReview};
13use super::rollup::{FileDisposition, FileRollup, moved_file_rollup};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum VirtualDiffImpactFileStatus {
17	Added,
18	Modified,
19	Deleted,
20	Renamed,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct VirtualDiffImpactDocument {
25	pub uri: String,
26	pub lang: Lang,
27	pub content: String,
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct VirtualDiffImpactFile {
32	pub status: VirtualDiffImpactFileStatus,
33	pub old_uri: Option<String>,
34	pub new_uri: Option<String>,
35	pub old_hunks: Vec<(u32, u32)>,
36	pub new_hunks: Vec<(u32, u32)>,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct VirtualDiffImpactInput {
41	pub scope: String,
42	pub project: Option<String>,
43	pub srcset: String,
44	pub base: Vec<VirtualDiffImpactDocument>,
45	pub head: Vec<VirtualDiffImpactDocument>,
46	pub files: Vec<VirtualDiffImpactFile>,
47}
48
49struct ExtractedDocument {
50	path: PathBuf,
51	lang: Lang,
52	content: String,
53	graph: CodeGraph,
54}
55
56struct DiffImpactPair<'a> {
57	old: &'a ExtractedDocument,
58	new: &'a ExtractedDocument,
59	status: VirtualDiffImpactFileStatus,
60	old_hunks: &'a [(u32, u32)],
61	new_hunks: &'a [(u32, u32)],
62}
63
64impl DiffImpactPair<'_> {
65	fn old_side(&self) -> FileSide<'_> {
66		FileSide {
67			lang: self.old.lang,
68			graph: &self.old.graph,
69			source: &self.old.content,
70			file_path: &self.old.path,
71		}
72	}
73
74	fn new_side(&self) -> FileSide<'_> {
75		FileSide {
76			lang: self.new.lang,
77			graph: &self.new.graph,
78			source: &self.new.content,
79			file_path: &self.new.path,
80		}
81	}
82
83	fn moved(&self) -> bool {
84		self.status == VirtualDiffImpactFileStatus::Renamed || self.old.path != self.new.path
85	}
86}
87
88pub fn build_virtual_diff_impact(input: VirtualDiffImpactInput) -> Result<SemanticReview, String> {
89	let context = ExtractContext {
90		project: input.project,
91		srcset: Some(input.srcset),
92		..ExtractContext::default()
93	};
94	let mut base = extract_documents(input.base, &context)?;
95	let mut head = extract_documents(input.head, &context)?;
96	let mut empties = Vec::new();
97	for file in &input.files {
98		ensure_empty_sides(file, &base, &head, &context, &mut empties)?;
99	}
100	let pairs = pair_documents(&input.files, &base, &head, &empties)?;
101	let pairings: Vec<FilePairing> = pairs
102		.iter()
103		.map(|pair| {
104			pair_file(PairInputs {
105				base: pair.old_side(),
106				current: pair.new_side(),
107				file_moved: pair.moved(),
108			})
109		})
110		.collect();
111	let symbol_changes = finish_files(pairings);
112	let mut rename_context = RenameContext::from_changes(&symbol_changes);
113	for pair in pairs.iter().filter(|pair| pair.moved()) {
114		rename_context.push_pair(pair.old.graph.root().clone(), pair.new.graph.root().clone());
115	}
116	let mut impact = SemanticReview {
117		scope: input.scope,
118		symbol_changes,
119		..SemanticReview::default()
120	};
121	for pair in &pairs {
122		let refs = pair_refs(&pair.old_side(), &pair.new_side(), &rename_context);
123		impact
124			.files
125			.push(file_facts(pair, &impact.symbol_changes, &refs));
126		impact.ref_changes.extend(refs);
127	}
128	impact.files.sort_by_key(|facts| {
129		facts
130			.rollup
131			.new_path
132			.clone()
133			.or_else(|| facts.rollup.old_path.clone())
134	});
135	base.clear();
136	head.clear();
137	Ok(impact)
138}
139
140fn extract_documents(
141	documents: Vec<VirtualDiffImpactDocument>,
142	context: &ExtractContext,
143) -> Result<BTreeMap<String, ExtractedDocument>, String> {
144	let mut out = BTreeMap::new();
145	for document in documents {
146		if out.contains_key(&document.uri) {
147			return Err(format!(
148				"duplicate virtual diff impact URI `{}`",
149				document.uri
150			));
151		}
152		let path = PathBuf::from(&document.uri);
153		let graph =
154			environment::extract_source_with(document.lang, &document.content, &path, context);
155		out.insert(
156			document.uri,
157			ExtractedDocument {
158				path,
159				lang: document.lang,
160				content: document.content,
161				graph,
162			},
163		);
164	}
165	Ok(out)
166}
167
168fn ensure_empty_sides(
169	file: &VirtualDiffImpactFile,
170	base: &BTreeMap<String, ExtractedDocument>,
171	head: &BTreeMap<String, ExtractedDocument>,
172	context: &ExtractContext,
173	empties: &mut Vec<ExtractedDocument>,
174) -> Result<(), String> {
175	let (missing_uri, reference) = match file.status {
176		VirtualDiffImpactFileStatus::Added => (
177			file.old_uri.as_deref().or(file.new_uri.as_deref()),
178			lookup(head, file.new_uri.as_deref())?,
179		),
180		VirtualDiffImpactFileStatus::Deleted => (
181			file.new_uri.as_deref().or(file.old_uri.as_deref()),
182			lookup(base, file.old_uri.as_deref())?,
183		),
184		_ => return Ok(()),
185	};
186	let uri =
187		missing_uri.ok_or_else(|| "virtual diff impact file is missing its path".to_string())?;
188	let path = PathBuf::from(uri);
189	empties.push(ExtractedDocument {
190		path: path.clone(),
191		lang: reference.lang,
192		content: String::new(),
193		graph: environment::extract_source_with(reference.lang, "", &path, context),
194	});
195	Ok(())
196}
197
198fn pair_documents<'a>(
199	files: &'a [VirtualDiffImpactFile],
200	base: &'a BTreeMap<String, ExtractedDocument>,
201	head: &'a BTreeMap<String, ExtractedDocument>,
202	empties: &'a [ExtractedDocument],
203) -> Result<Vec<DiffImpactPair<'a>>, String> {
204	let mut empty_idx = 0usize;
205	let pairs: Vec<DiffImpactPair<'a>> = files
206		.iter()
207		.map(|file| {
208			let (old, new) = match file.status {
209				VirtualDiffImpactFileStatus::Added => {
210					let empty = empties
211						.get(empty_idx)
212						.ok_or_else(|| "missing empty base side".to_string())?;
213					empty_idx += 1;
214					(empty, lookup(head, file.new_uri.as_deref())?)
215				}
216				VirtualDiffImpactFileStatus::Deleted => {
217					let empty = empties
218						.get(empty_idx)
219						.ok_or_else(|| "missing empty head side".to_string())?;
220					empty_idx += 1;
221					(lookup(base, file.old_uri.as_deref())?, empty)
222				}
223				VirtualDiffImpactFileStatus::Modified | VirtualDiffImpactFileStatus::Renamed => (
224					lookup(base, file.old_uri.as_deref())?,
225					lookup(head, file.new_uri.as_deref())?,
226				),
227			};
228			if old.lang != new.lang {
229				return Err(format!(
230					"language changed between `{}` and `{}`",
231					old.path.display(),
232					new.path.display()
233				));
234			}
235			Ok(DiffImpactPair {
236				old,
237				new,
238				status: file.status,
239				old_hunks: &file.old_hunks,
240				new_hunks: &file.new_hunks,
241			})
242		})
243		.collect::<Result<_, _>>()?;
244	validate_document_coverage(files, base, head)?;
245	Ok(pairs)
246}
247
248fn validate_document_coverage(
249	files: &[VirtualDiffImpactFile],
250	base: &BTreeMap<String, ExtractedDocument>,
251	head: &BTreeMap<String, ExtractedDocument>,
252) -> Result<(), String> {
253	let mut old_uris = BTreeSet::new();
254	let mut new_uris = BTreeSet::new();
255	for file in files {
256		if let Some(uri) = &file.old_uri
257			&& !old_uris.insert(uri.as_str())
258		{
259			return Err(format!("duplicate old diff-impact URI `{uri}`"));
260		}
261		if let Some(uri) = &file.new_uri
262			&& !new_uris.insert(uri.as_str())
263		{
264			return Err(format!("duplicate new diff-impact URI `{uri}`"));
265		}
266	}
267	if let Some(uri) = base.keys().find(|uri| !old_uris.contains(uri.as_str())) {
268		return Err(format!(
269			"base diff-impact document `{uri}` is absent from the file inventory"
270		));
271	}
272	if let Some(uri) = head.keys().find(|uri| !new_uris.contains(uri.as_str())) {
273		return Err(format!(
274			"head diff-impact document `{uri}` is absent from the file inventory"
275		));
276	}
277	Ok(())
278}
279
280fn lookup<'a>(
281	documents: &'a BTreeMap<String, ExtractedDocument>,
282	uri: Option<&str>,
283) -> Result<&'a ExtractedDocument, String> {
284	let uri = uri.ok_or_else(|| "virtual diff impact file is missing its path".to_string())?;
285	documents
286		.get(uri)
287		.ok_or_else(|| format!("virtual diff impact document `{uri}` is missing"))
288}
289
290fn file_facts(
291	pair: &DiffImpactPair<'_>,
292	changes: &[SymbolChange],
293	refs: &[RefChange],
294) -> FileFacts {
295	let file_changes: Vec<SymbolChange> = changes
296		.iter()
297		.filter(|change| {
298			change
299				.old
300				.as_ref()
301				.is_some_and(|side| side.file_path == pair.old.path)
302				|| change
303					.new
304					.as_ref()
305					.is_some_and(|side| side.file_path == pair.new.path)
306		})
307		.cloned()
308		.collect();
309	let coverage = coverage(pair, &file_changes, refs);
310	let disposition = match pair.status {
311		VirtualDiffImpactFileStatus::Added => FileDisposition::Added,
312		VirtualDiffImpactFileStatus::Deleted => FileDisposition::Removed,
313		VirtualDiffImpactFileStatus::Modified => FileDisposition::Modified,
314		VirtualDiffImpactFileStatus::Renamed => FileDisposition::Moved { pure: true },
315	};
316	let mut rollup = if pair.moved() {
317		moved_file_rollup(pair.old.path.clone(), pair.new.path.clone(), &file_changes)
318	} else {
319		FileRollup {
320			old_path: (pair.status != VirtualDiffImpactFileStatus::Added)
321				.then(|| pair.old.path.clone()),
322			new_path: (pair.status != VirtualDiffImpactFileStatus::Deleted)
323				.then(|| pair.new.path.clone()),
324			disposition,
325			symbol_changes: file_changes.len(),
326			moved_symbols: 0,
327		}
328	};
329	if rollup.disposition == (FileDisposition::Moved { pure: true }) && !coverage.explained() {
330		rollup.disposition = FileDisposition::Moved { pure: false };
331	}
332	FileFacts {
333		rollup,
334		coverage,
335		analyzable: true,
336	}
337}
338
339fn coverage(
340	pair: &DiffImpactPair<'_>,
341	changes: &[SymbolChange],
342	refs: &[RefChange],
343) -> HunkCoverage {
344	let old_explained = explained_ranges(changes, refs, true, &pair.old.path);
345	let new_explained = explained_ranges(changes, refs, false, &pair.new.path);
346	hunk_coverage(CoverageInputs {
347		old_hunks: pair.old_hunks,
348		new_hunks: pair.new_hunks,
349		old_explained: &old_explained,
350		new_explained: &new_explained,
351	})
352}
353
354fn explained_ranges(
355	changes: &[SymbolChange],
356	refs: &[RefChange],
357	old: bool,
358	path: &Path,
359) -> Vec<(u32, u32)> {
360	let mut ranges: Vec<(u32, u32)> = changes
361		.iter()
362		.filter_map(|change| {
363			if old {
364				change.old.as_ref()
365			} else {
366				change.new.as_ref()
367			}
368		})
369		.filter(|side| side.file_path == path)
370		.filter_map(|side| side.line_range)
371		.collect();
372	for reference in refs.iter().filter(|reference| reference.file_path == path) {
373		if let Some(range) = if old {
374			reference.old_line_range
375		} else {
376			reference.new_line_range
377		} {
378			ranges.push(range);
379		}
380	}
381	ranges
382}
383
384#[cfg(test)]
385mod tests {
386	use super::super::model::SemanticKind;
387	use super::*;
388
389	fn document(uri: &str, content: &str) -> VirtualDiffImpactDocument {
390		VirtualDiffImpactDocument {
391			uri: uri.to_string(),
392			lang: Lang::Rs,
393			content: content.to_string(),
394		}
395	}
396
397	#[test]
398	fn compares_virtual_documents_without_a_workspace() {
399		let impact = build_virtual_diff_impact(VirtualDiffImpactInput {
400			scope: "base..head".to_string(),
401			project: Some("sample".to_string()),
402			srcset: "diff-impact".to_string(),
403			base: vec![document(
404				"src/lib.rs",
405				"pub fn kept() { old(); }\npub fn removed() { obsolete(); }\n",
406			)],
407			head: vec![document(
408				"src/lib.rs",
409				"pub fn kept() { fresh(); }\npub fn added() { created(); }\n",
410			)],
411			files: vec![VirtualDiffImpactFile {
412				status: VirtualDiffImpactFileStatus::Modified,
413				old_uri: Some("src/lib.rs".to_string()),
414				new_uri: Some("src/lib.rs".to_string()),
415				old_hunks: vec![(1, 2)],
416				new_hunks: vec![(1, 2)],
417			}],
418		})
419		.expect("virtual diff impact");
420
421		assert!(
422			impact
423				.symbol_changes
424				.iter()
425				.any(|change| change.kind == SemanticKind::BodyModified)
426		);
427		assert!(
428			impact
429				.symbol_changes
430				.iter()
431				.any(|change| change.kind == SemanticKind::Removed)
432		);
433		assert!(
434			impact
435				.symbol_changes
436				.iter()
437				.any(|change| change.kind == SemanticKind::Added)
438		);
439		assert!(!impact.ref_changes.is_empty());
440	}
441
442	#[test]
443	fn compares_added_and_deleted_virtual_files() {
444		let impact = build_virtual_diff_impact(VirtualDiffImpactInput {
445			scope: "base..head".to_string(),
446			project: Some("sample".to_string()),
447			srcset: "diff-impact".to_string(),
448			base: vec![document(
449				"src/removed.rs",
450				"pub fn removed_file_symbol() {}\n",
451			)],
452			head: vec![document("src/added.rs", "pub fn added_file_symbol() {}\n")],
453			files: vec![
454				VirtualDiffImpactFile {
455					status: VirtualDiffImpactFileStatus::Deleted,
456					old_uri: Some("src/removed.rs".to_string()),
457					new_uri: None,
458					old_hunks: vec![(1, 1)],
459					new_hunks: vec![],
460				},
461				VirtualDiffImpactFile {
462					status: VirtualDiffImpactFileStatus::Added,
463					old_uri: None,
464					new_uri: Some("src/added.rs".to_string()),
465					old_hunks: vec![],
466					new_hunks: vec![(1, 1)],
467				},
468			],
469		})
470		.expect("virtual diff impact");
471
472		assert_eq!(impact.files.len(), 2);
473		assert!(
474			impact
475				.files
476				.iter()
477				.any(|file| file.rollup.disposition == FileDisposition::Added)
478		);
479		assert!(
480			impact
481				.files
482				.iter()
483				.any(|file| file.rollup.disposition == FileDisposition::Removed)
484		);
485		assert!(
486			impact
487				.symbol_changes
488				.iter()
489				.any(|change| change.kind == SemanticKind::Added)
490		);
491		assert!(
492			impact
493				.symbol_changes
494				.iter()
495				.any(|change| change.kind == SemanticKind::Removed)
496		);
497	}
498
499	#[test]
500	fn rejects_documents_missing_from_the_authoritative_file_inventory() {
501		let error = build_virtual_diff_impact(VirtualDiffImpactInput {
502			scope: "base..head".to_string(),
503			project: Some("sample".to_string()),
504			srcset: "diff-impact".to_string(),
505			base: vec![document("src/lib.rs", "pub fn hidden() {}\n")],
506			head: vec![document("src/lib.rs", "pub fn hidden() {}\n")],
507			files: vec![],
508		})
509		.expect_err("unlisted documents must fail closed");
510		assert!(error.contains("absent from the file inventory"), "{error}");
511	}
512
513	#[test]
514	fn a_pure_file_rename_is_not_reported_as_add_remove() {
515		let source = "pub struct Service;\nimpl Service { pub fn run(&self) {} }\n";
516		let impact = build_virtual_diff_impact(VirtualDiffImpactInput {
517			scope: "base..head".to_string(),
518			project: Some("sample".to_string()),
519			srcset: "diff-impact".to_string(),
520			base: vec![document("src/old.rs", source)],
521			head: vec![document("src/new.rs", source)],
522			files: vec![VirtualDiffImpactFile {
523				status: VirtualDiffImpactFileStatus::Renamed,
524				old_uri: Some("src/old.rs".to_string()),
525				new_uri: Some("src/new.rs".to_string()),
526				old_hunks: vec![],
527				new_hunks: vec![],
528			}],
529		})
530		.expect("virtual diff impact");
531
532		assert!(
533			impact
534				.symbol_changes
535				.iter()
536				.all(|change| change.kind == SemanticKind::Moved)
537		);
538		assert_eq!(
539			impact.files[0].rollup.disposition,
540			FileDisposition::Moved { pure: true }
541		);
542	}
543}