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}