1use std::collections::{BTreeSet, HashMap};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::process::Command;
5use std::sync::{Mutex, OnceLock};
6
7use crate::DiffkitResult;
8use crate::diff::{
9 DiffNode, collapse_unchanged_subtrees, diff_optional, nodes_equivalent, tree_has_changes,
10 truncate_diff_tree,
11};
12use crate::graph::ProgramGraph;
13use crate::language::ocaml::OcamlFrontend;
14use crate::language::rust::{
15 RustProjectSession, analyze_semantic_file_with_entries, analyze_semantic_project,
16 analyze_semantic_source,
17};
18use crate::language::{FileContext, LanguageBackend, ProjectContext};
19use crate::model::{CallNode, FileAnalysis, SymbolId};
20use crate::source::{collect_source_files, paths_match};
21
22static CARGO_SOURCE_LAYOUTS: OnceLock<Mutex<HashMap<PathBuf, Option<CargoSourceLayout>>>> =
23 OnceLock::new();
24
25#[derive(Clone)]
26struct CargoSourceLayout {
27 workspace_root: PathBuf,
28 package_roots: Vec<PathBuf>,
29 target_sources: Vec<PathBuf>,
30}
31
32#[derive(Clone, Debug)]
33pub struct DiffOptions {
34 pub entries: Vec<String>,
35 pub max_depth: usize,
36}
37
38impl Default for DiffOptions {
39 fn default() -> Self {
40 Self {
41 entries: Vec::new(),
42 max_depth: 8,
43 }
44 }
45}
46
47#[derive(Clone, Debug)]
48pub struct EntryDiff {
49 pub entry: SymbolId,
50 pub tree: DiffNode,
51}
52
53#[derive(Clone, Debug)]
54pub struct DiffReport {
55 pub language: String,
56 pub before: String,
57 pub after: String,
58 pub trees: Vec<EntryDiff>,
59 pub message: Option<String>,
60 pub analyzed_files: BTreeSet<PathBuf>,
61 pub diagnostics: Vec<String>,
62}
63
64impl DiffReport {
65 pub fn analyzes_path(&self, path: &Path) -> bool {
66 self.analyzed_files
67 .iter()
68 .any(|analyzed| paths_match(analyzed, path))
69 }
70}
71
72#[derive(Clone, Debug)]
73pub struct EntryTree {
74 pub entry: SymbolId,
75 pub tree: CallNode,
76}
77
78#[derive(Clone, Debug)]
79pub struct TreeReport {
80 pub language: String,
81 pub source: String,
82 pub trees: Vec<EntryTree>,
83 pub message: Option<String>,
84 pub diagnostics: Vec<String>,
85}
86
87pub fn rustdiff_sources(
88 before_name: impl Into<String>,
89 before_source: &str,
90 after_name: impl Into<String>,
91 after_source: &str,
92 options: &DiffOptions,
93) -> DiffkitResult<DiffReport> {
94 let before = graph_from_files([analyze_semantic_source(before_source, &options.entries)?])?;
95 let after = graph_from_files([analyze_semantic_source(after_source, &options.entries)?])?;
96 build_report(
97 "rust".to_owned(),
98 before_name.into(),
99 after_name.into(),
100 &before,
101 &after,
102 options,
103 )
104}
105
106pub fn rustdiff_paths(
107 before_path: &Path,
108 after_path: &Path,
109 options: &DiffOptions,
110) -> DiffkitResult<DiffReport> {
111 let before = graph_from_files([analyze_semantic_file_with_entries(
112 before_path,
113 &options.entries,
114 )?])?;
115 let after = graph_from_files([analyze_semantic_file_with_entries(
116 after_path,
117 &options.entries,
118 )?])?;
119 build_report(
120 "rust".to_owned(),
121 before_path.display().to_string(),
122 after_path.display().to_string(),
123 &before,
124 &after,
125 options,
126 )
127}
128
129pub fn rustdiff_project_paths(
130 before_root: &Path,
131 after_root: &Path,
132 wrapper_executable: &Path,
133 options: &DiffOptions,
134) -> DiffkitResult<DiffReport> {
135 let session = RustProjectSession::create()?;
136 rustdiff_project_paths_with_session(
137 before_root,
138 after_root,
139 wrapper_executable,
140 options,
141 &session,
142 )
143}
144
145pub fn rustdiff_project_paths_cached(
146 before_root: &Path,
147 after_root: &Path,
148 cache_project_root: &Path,
149 wrapper_executable: &Path,
150 options: &DiffOptions,
151 verbose: bool,
152) -> DiffkitResult<DiffReport> {
153 let mut before_session =
154 RustProjectSession::create_cached(cache_project_root, "git-before", verbose)?;
155 let mut after_session =
156 RustProjectSession::create_cached(cache_project_root, "git-after", verbose)?;
157 limit_parallel_cargo_jobs(&mut before_session, &mut after_session);
158 rustdiff_project_paths_with_sessions(
159 before_root,
160 after_root,
161 wrapper_executable,
162 options,
163 &before_session,
164 &after_session,
165 )
166}
167
168fn rustdiff_project_paths_with_session(
169 before_root: &Path,
170 after_root: &Path,
171 wrapper_executable: &Path,
172 options: &DiffOptions,
173 session: &RustProjectSession,
174) -> DiffkitResult<DiffReport> {
175 rustdiff_project_paths_with_sessions(
176 before_root,
177 after_root,
178 wrapper_executable,
179 options,
180 session,
181 session,
182 )
183}
184
185fn rustdiff_project_paths_with_sessions(
186 before_root: &Path,
187 after_root: &Path,
188 wrapper_executable: &Path,
189 options: &DiffOptions,
190 before_session: &RustProjectSession,
191 after_session: &RustProjectSession,
192) -> DiffkitResult<DiffReport> {
193 let (before, after) = if std::ptr::eq(before_session, after_session) {
194 (
195 rust_project_graph(
196 before_root,
197 wrapper_executable,
198 &options.entries,
199 before_session,
200 )?,
201 rust_project_graph(
202 after_root,
203 wrapper_executable,
204 &options.entries,
205 after_session,
206 )?,
207 )
208 } else {
209 rust_project_graphs_parallel(
210 before_root,
211 after_root,
212 wrapper_executable,
213 &options.entries,
214 before_session,
215 after_session,
216 )?
217 };
218 build_report(
219 "rust".to_owned(),
220 before_root.display().to_string(),
221 after_root.display().to_string(),
222 &before,
223 &after,
224 options,
225 )
226}
227
228pub fn rustdiff_project_files(
229 before_file: &Path,
230 after_file: &Path,
231 wrapper_executable: &Path,
232 options: &DiffOptions,
233 verbose: bool,
234) -> DiffkitResult<Option<DiffReport>> {
235 let before_root = find_cargo_root(before_file)?;
236 let after_root = find_cargo_root(after_file)?;
237 let mut before_session =
238 RustProjectSession::create_cached(&before_root, "file-before", verbose)?;
239 let mut after_session = RustProjectSession::create_cached(&after_root, "file-after", verbose)?;
240 limit_parallel_cargo_jobs(&mut before_session, &mut after_session);
241 let (before, after) = rust_project_graphs_parallel(
242 &before_root,
243 &after_root,
244 wrapper_executable,
245 &options.entries,
246 &before_session,
247 &after_session,
248 )?;
249 if !before.analyzes_file(before_file) && !after.analyzes_file(after_file) {
250 return Ok(None);
251 }
252 build_file_report(
253 "rust".to_owned(),
254 before_file,
255 after_file,
256 &before,
257 &after,
258 options,
259 )
260 .map(Some)
261}
262
263pub fn ocamldiff_sources(
264 before_name: impl Into<String>,
265 before_source: &str,
266 after_name: impl Into<String>,
267 after_source: &str,
268 options: &DiffOptions,
269) -> DiffkitResult<DiffReport> {
270 let frontend = OcamlFrontend;
271 diff_sources(
272 before_name.into(),
273 before_source,
274 after_name.into(),
275 after_source,
276 &frontend,
277 options,
278 )
279}
280
281pub fn ocamldiff_paths(
282 before_path: &Path,
283 after_path: &Path,
284 options: &DiffOptions,
285) -> DiffkitResult<DiffReport> {
286 let frontend = OcamlFrontend;
287 let before = ocaml_optional_project_graph(before_path, &frontend)?;
288 let after = ocaml_optional_project_graph(after_path, &frontend)?;
289 build_report(
290 frontend.language().0,
291 before_path.display().to_string(),
292 after_path.display().to_string(),
293 &before,
294 &after,
295 options,
296 )
297}
298
299pub fn ocamldiff_project_files(
300 before_file: &Path,
301 after_file: &Path,
302 options: &DiffOptions,
303) -> DiffkitResult<DiffReport> {
304 let frontend = OcamlFrontend;
305 let before_root = find_ocaml_root(before_file);
306 let after_root = find_ocaml_root(after_file);
307 let before = ocaml_project_graph(&before_root, &frontend)?;
308 let after = ocaml_project_graph(&after_root, &frontend)?;
309 build_file_report(
310 frontend.language().0,
311 before_file,
312 after_file,
313 &before,
314 &after,
315 options,
316 )
317}
318
319pub fn rusttree_path(path: &Path, options: &DiffOptions) -> DiffkitResult<TreeReport> {
320 let graph = graph_from_files([analyze_semantic_file_with_entries(path, &options.entries)?])?;
321 build_tree_report("rust", path, &graph, options)
322}
323
324pub fn rusttree_project_file(
325 path: &Path,
326 wrapper_executable: &Path,
327 options: &DiffOptions,
328 verbose: bool,
329) -> DiffkitResult<Option<TreeReport>> {
330 let root = find_cargo_root(path)?;
331 let session = RustProjectSession::create_cached(&root, "tree", verbose)?;
332 let graph = rust_project_graph(&root, wrapper_executable, &options.entries, &session)?;
333 if !graph.analyzes_file(path) {
334 return Ok(None);
335 }
336 build_tree_report("rust", path, &graph, options).map(Some)
337}
338
339pub fn ocamltree_path(path: &Path, options: &DiffOptions) -> DiffkitResult<TreeReport> {
340 let frontend = OcamlFrontend;
341 let graph = load_path(path, &frontend)?;
342 build_tree_report("ocaml", path, &graph, options)
343}
344
345pub fn ocamltree_project_file(path: &Path, options: &DiffOptions) -> DiffkitResult<TreeReport> {
346 let frontend = OcamlFrontend;
347 let root = find_ocaml_root(path);
348 let graph = ocaml_project_graph(&root, &frontend)?;
349 build_tree_report("ocaml", path, &graph, options)
350}
351
352fn diff_sources(
353 before_name: String,
354 before_source: &str,
355 after_name: String,
356 after_source: &str,
357 frontend: &impl LanguageBackend,
358 options: &DiffOptions,
359) -> DiffkitResult<DiffReport> {
360 let empty_module = Vec::new();
361 let before_analysis = frontend.analyze_file(
362 &FileContext {
363 path: Path::new(&before_name),
364 module: &empty_module,
365 },
366 before_source,
367 )?;
368 let after_analysis = frontend.analyze_file(
369 &FileContext {
370 path: Path::new(&after_name),
371 module: &empty_module,
372 },
373 after_source,
374 )?;
375 let before = graph_from_files([before_analysis])?;
376 let after = graph_from_files([after_analysis])?;
377 build_report(
378 frontend.language().0,
379 before_name,
380 after_name,
381 &before,
382 &after,
383 options,
384 )
385}
386
387fn build_report(
388 language: String,
389 before_name: String,
390 after_name: String,
391 before: &ProgramGraph,
392 after: &ProgramGraph,
393 options: &DiffOptions,
394) -> DiffkitResult<DiffReport> {
395 let candidate_entries = if options.entries.is_empty() {
396 ordered_root_union(before, after)
397 } else {
398 resolve_explicit_entries(before, after, &options.entries)?
399 .into_iter()
400 .collect()
401 };
402
403 let trees = changed_entries(before, after, candidate_entries, options.max_depth);
404 let mut report = report_from_trees(language, before_name, after_name, trees);
405 report.analyzed_files.extend(before.source_files());
406 report.analyzed_files.extend(after.source_files());
407 report.diagnostics = comparison_diagnostics(before, after);
408 Ok(report)
409}
410
411fn build_file_report(
412 language: String,
413 before_path: &Path,
414 after_path: &Path,
415 before: &ProgramGraph,
416 after: &ProgramGraph,
417 options: &DiffOptions,
418) -> DiffkitResult<DiffReport> {
419 let candidate_entries = if options.entries.is_empty() {
420 ordered_file_root_union(before, after, before_path, after_path)
421 } else {
422 resolve_explicit_file_entries(before, after, before_path, after_path, &options.entries)?
423 .into_iter()
424 .collect()
425 };
426 let trees = changed_entries_in_files(
427 before,
428 after,
429 candidate_entries,
430 options.max_depth,
431 Some(before_path),
432 Some(after_path),
433 );
434 let mut report = report_from_trees(
435 language,
436 before_path.display().to_string(),
437 after_path.display().to_string(),
438 trees,
439 );
440 report.analyzed_files.extend(before.source_files());
441 report.analyzed_files.extend(after.source_files());
442 report.diagnostics = comparison_diagnostics(before, after);
443 Ok(report)
444}
445
446fn build_tree_report(
447 language: &str,
448 path: &Path,
449 graph: &ProgramGraph,
450 options: &DiffOptions,
451) -> DiffkitResult<TreeReport> {
452 let entries = if options.entries.is_empty() {
453 graph.roots_in_file(path).into_iter().collect::<Vec<_>>()
454 } else {
455 let mut resolved = Vec::new();
456 for entry in &options.entries {
457 let matches = graph
458 .resolve_entries_in_file(entry, path)
459 .map_err(std::io::Error::other)?;
460 if matches.is_empty() {
461 return Err(std::io::Error::other(format!(
462 "entry not found in selected file: {entry}"
463 ))
464 .into());
465 }
466 resolved.extend(matches);
467 }
468 resolved
469 };
470
471 let trees = entries
472 .into_iter()
473 .filter_map(|entry| {
474 graph
475 .build_call_tree_in_file(&entry, options.max_depth, Some(path))
476 .map(|tree| EntryTree { entry, tree })
477 })
478 .collect::<Vec<_>>();
479 let message = trees
480 .is_empty()
481 .then(|| format!("No {language} call trees in {}.", path.display()));
482 Ok(TreeReport {
483 language: language.to_owned(),
484 source: path.display().to_string(),
485 trees,
486 message,
487 diagnostics: graph.resolution_diagnostics(),
488 })
489}
490
491fn ordered_root_union(before: &ProgramGraph, after: &ProgramGraph) -> Vec<SymbolId> {
492 let before_roots = before.inferred_roots();
493 let after_roots = after.inferred_roots();
494 let mut common = before_roots
495 .intersection(&after_roots)
496 .cloned()
497 .collect::<Vec<_>>();
498 let mut removed = before_roots
499 .difference(&after_roots)
500 .cloned()
501 .collect::<Vec<_>>();
502 let mut added = after_roots
503 .difference(&before_roots)
504 .cloned()
505 .collect::<Vec<_>>();
506 sort_by_source(&mut common, before);
507 sort_by_source(&mut removed, before);
508 sort_by_source(&mut added, after);
509 common.extend(removed);
510 common.extend(added);
511 common
512}
513
514fn ordered_file_root_union(
515 before: &ProgramGraph,
516 after: &ProgramGraph,
517 before_path: &Path,
518 after_path: &Path,
519) -> Vec<SymbolId> {
520 let before_roots = before.roots_in_file(before_path);
521 let after_roots = after.roots_in_file(after_path);
522 let mut common = before_roots
523 .intersection(&after_roots)
524 .cloned()
525 .collect::<Vec<_>>();
526 let mut removed = before_roots
527 .difference(&after_roots)
528 .cloned()
529 .collect::<Vec<_>>();
530 let mut added = after_roots
531 .difference(&before_roots)
532 .cloned()
533 .collect::<Vec<_>>();
534 sort_by_source(&mut common, before);
535 sort_by_source(&mut removed, before);
536 sort_by_source(&mut added, after);
537 common.extend(removed);
538 common.extend(added);
539 common
540}
541
542fn sort_by_source(symbols: &mut [SymbolId], graph: &ProgramGraph) {
543 symbols.sort_by_key(|symbol| {
544 graph.functions().get(symbol).map_or_else(
545 || (PathBuf::new(), usize::MAX, usize::MAX, symbol.clone()),
546 |function| {
547 (
548 function.span.file.clone(),
549 function.span.start_line,
550 function.span.start_column,
551 symbol.clone(),
552 )
553 },
554 )
555 });
556}
557
558fn report_from_trees(
559 language: String,
560 before: String,
561 after: String,
562 trees: Vec<EntryDiff>,
563) -> DiffReport {
564 let message = trees
565 .is_empty()
566 .then(|| format!("No {language} call changes between {before} and {after}."));
567 DiffReport {
568 language,
569 before,
570 after,
571 trees,
572 message,
573 analyzed_files: BTreeSet::new(),
574 diagnostics: Vec::new(),
575 }
576}
577
578fn comparison_diagnostics(before: &ProgramGraph, after: &ProgramGraph) -> Vec<String> {
579 before
580 .resolution_diagnostics()
581 .into_iter()
582 .map(|diagnostic| format!("before: {diagnostic}"))
583 .chain(
584 after
585 .resolution_diagnostics()
586 .into_iter()
587 .map(|diagnostic| format!("after: {diagnostic}")),
588 )
589 .collect()
590}
591
592fn resolve_explicit_entries(
593 before: &ProgramGraph,
594 after: &ProgramGraph,
595 entries: &[String],
596) -> DiffkitResult<BTreeSet<SymbolId>> {
597 let mut resolved = BTreeSet::new();
598 for entry in entries {
599 let before_entries = before
600 .resolve_entries(entry)
601 .map_err(std::io::Error::other)?;
602 let after_entries = after
603 .resolve_entries(entry)
604 .map_err(std::io::Error::other)?;
605 if before_entries.is_empty() && after_entries.is_empty() {
606 return Err(std::io::Error::other(format!("entry not found: {entry}")).into());
607 }
608 resolved.extend(before_entries);
609 resolved.extend(after_entries);
610 }
611 Ok(resolved)
612}
613
614fn resolve_explicit_file_entries(
615 before: &ProgramGraph,
616 after: &ProgramGraph,
617 before_path: &Path,
618 after_path: &Path,
619 entries: &[String],
620) -> DiffkitResult<BTreeSet<SymbolId>> {
621 let mut resolved = BTreeSet::new();
622 for entry in entries {
623 let before_entries = before
624 .resolve_entries_in_file(entry, before_path)
625 .map_err(std::io::Error::other)?;
626 let after_entries = after
627 .resolve_entries_in_file(entry, after_path)
628 .map_err(std::io::Error::other)?;
629 if before_entries.is_empty() && after_entries.is_empty() {
630 return Err(std::io::Error::other(format!(
631 "entry not found in selected file: {entry}"
632 ))
633 .into());
634 }
635 resolved.extend(before_entries);
636 resolved.extend(after_entries);
637 }
638 Ok(resolved)
639}
640
641fn changed_entries(
642 before: &ProgramGraph,
643 after: &ProgramGraph,
644 entries: impl IntoIterator<Item = SymbolId>,
645 max_depth: usize,
646) -> Vec<EntryDiff> {
647 changed_entries_in_files(before, after, entries, max_depth, None, None)
648}
649
650fn changed_entries_in_files(
651 before: &ProgramGraph,
652 after: &ProgramGraph,
653 entries: impl IntoIterator<Item = SymbolId>,
654 max_depth: usize,
655 before_file: Option<&Path>,
656 after_file: Option<&Path>,
657) -> Vec<EntryDiff> {
658 let local_changes = locally_changed_symbols(before, after, before_file, after_file);
659 let before_relevant = before.symbols_reaching(&local_changes, before_file);
660 let after_relevant = after.symbols_reaching(&local_changes, after_file);
661 let before_context = before.dispatch_context_symbols(before_file);
662 let after_context = after.dispatch_context_symbols(after_file);
663 let expansion_depth = max_depth.saturating_add(1);
664
665 entries
666 .into_iter()
667 .filter_map(|entry| {
668 let before_root = before.build_call_tree_in_file(&entry, 0, before_file);
669 let after_root = after.build_call_tree_in_file(&entry, 0, after_file);
670 let root_changed = call_trees_differ(before_root.as_ref(), after_root.as_ref());
671 if !root_changed
672 && !before_relevant.contains(&entry)
673 && !after_relevant.contains(&entry)
674 {
675 return None;
676 }
677
678 let before_tree = before.build_diff_call_tree(
679 &entry,
680 expansion_depth,
681 before_file,
682 &before_relevant,
683 &before_context,
684 "diffkit-before-boundary",
685 );
686 let after_tree = after.build_diff_call_tree(
687 &entry,
688 expansion_depth,
689 after_file,
690 &after_relevant,
691 &after_context,
692 "diffkit-after-boundary",
693 );
694 let mut tree = diff_optional(before_tree.as_ref(), after_tree.as_ref())?;
695 tree_has_changes(&tree).then(|| {
696 collapse_unchanged_subtrees(&mut tree);
697 truncate_diff_tree(&mut tree, max_depth);
698 EntryDiff { entry, tree }
699 })
700 })
701 .collect()
702}
703
704fn locally_changed_symbols(
705 before: &ProgramGraph,
706 after: &ProgramGraph,
707 before_file: Option<&Path>,
708 after_file: Option<&Path>,
709) -> BTreeSet<SymbolId> {
710 before
711 .functions()
712 .keys()
713 .chain(after.functions().keys())
714 .cloned()
715 .collect::<BTreeSet<_>>()
716 .into_iter()
717 .filter(|symbol| {
718 let before_shape = before.local_call_shape(symbol, before_file);
719 let after_shape = after.local_call_shape(symbol, after_file);
720 call_trees_differ(before_shape.as_ref(), after_shape.as_ref())
721 })
722 .collect()
723}
724
725fn call_trees_differ(before: Option<&CallNode>, after: Option<&CallNode>) -> bool {
726 match (before, after) {
727 (Some(before), Some(after)) => !nodes_equivalent(before, after),
728 (None, None) => false,
729 (Some(_), None) | (None, Some(_)) => true,
730 }
731}
732
733fn load_path(path: &Path, frontend: &impl LanguageBackend) -> DiffkitResult<ProgramGraph> {
734 if !path.exists() {
735 return Err(std::io::Error::new(
736 std::io::ErrorKind::NotFound,
737 format!("path does not exist: {}", path.display()),
738 )
739 .into());
740 }
741
742 let files = collect_source_files(path, frontend.extensions())?;
743 if files.is_empty() {
744 return Err(std::io::Error::other(format!(
745 "no {} source files found under {}",
746 frontend.language(),
747 path.display()
748 ))
749 .into());
750 }
751
752 let single_file = path.is_file();
753 let root = if single_file {
754 path.parent().unwrap_or_else(|| Path::new("."))
755 } else {
756 path
757 };
758 let mut analyses = Vec::with_capacity(files.len());
759 for file in files {
760 let source = fs::read_to_string(&file)?;
761 let module = if single_file {
762 Vec::new()
763 } else {
764 module_path(root, &file)
765 };
766 analyses.push(frontend.analyze_file(
767 &FileContext {
768 path: &file,
769 module: &module,
770 },
771 &source,
772 )?);
773 }
774 graph_from_files(analyses)
775}
776
777fn graph_from_files(files: impl IntoIterator<Item = FileAnalysis>) -> DiffkitResult<ProgramGraph> {
778 ProgramGraph::from_files(files)
779 .map_err(std::io::Error::other)
780 .map_err(Into::into)
781}
782
783fn rust_project_graph(
784 root: &Path,
785 wrapper_executable: &Path,
786 entries: &[String],
787 session: &RustProjectSession,
788) -> DiffkitResult<ProgramGraph> {
789 if !root.join("Cargo.toml").is_file() {
790 return graph_from_files(std::iter::empty());
791 }
792 graph_from_files([analyze_semantic_project(
793 root,
794 wrapper_executable,
795 entries,
796 session,
797 )?])
798}
799
800fn rust_project_graphs_parallel(
801 before_root: &Path,
802 after_root: &Path,
803 wrapper_executable: &Path,
804 entries: &[String],
805 before_session: &RustProjectSession,
806 after_session: &RustProjectSession,
807) -> DiffkitResult<(ProgramGraph, ProgramGraph)> {
808 std::thread::scope(|scope| {
809 let before = scope
810 .spawn(|| rust_project_graph(before_root, wrapper_executable, entries, before_session));
811 let after = scope
812 .spawn(|| rust_project_graph(after_root, wrapper_executable, entries, after_session));
813 let before = before
814 .join()
815 .map_err(|_| std::io::Error::other("before Rust analysis worker panicked"))??;
816 let after = after
817 .join()
818 .map_err(|_| std::io::Error::other("after Rust analysis worker panicked"))??;
819 Ok((before, after))
820 })
821}
822
823fn limit_parallel_cargo_jobs(before: &mut RustProjectSession, after: &mut RustProjectSession) {
824 let Some(parallelism) = std::thread::available_parallelism().ok() else {
825 return;
826 };
827 let jobs = (parallelism.get() / 2).max(1);
828 before.limit_cargo_jobs(jobs);
829 after.limit_cargo_jobs(jobs);
830}
831
832fn ocaml_project_graph(
833 root: &Path,
834 frontend: &impl LanguageBackend,
835) -> DiffkitResult<ProgramGraph> {
836 if root.is_dir() {
837 return graph_from_files([frontend.analyze_project(&ProjectContext {
838 root,
839 driver_executable: None,
840 entries: &[],
841 cache: None,
842 })?]);
843 }
844 load_path(root, frontend)
845}
846
847fn ocaml_optional_project_graph(
848 root: &Path,
849 frontend: &impl LanguageBackend,
850) -> DiffkitResult<ProgramGraph> {
851 if !root.exists() {
852 return graph_from_files(std::iter::empty());
853 }
854 ocaml_project_graph(root, frontend)
855}
856
857pub fn find_cargo_root(path: &Path) -> DiffkitResult<PathBuf> {
858 let start = if path.is_dir() {
859 path
860 } else {
861 path.parent().unwrap_or_else(|| Path::new("."))
862 };
863 for ancestor in start.ancestors() {
864 if ancestor.join("Cargo.toml").is_file() {
865 return Ok(ancestor.canonicalize()?);
866 }
867 }
868 Err(std::io::Error::other(format!("no Cargo.toml found above {}", path.display())).into())
869}
870
871pub fn rust_file_is_project_source(path: &Path) -> bool {
876 let Ok(root) = find_cargo_root(path) else {
877 return false;
878 };
879 let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
880 let Some(layout) = cargo_source_layout(&root) else {
881 return conventional_rust_source(&absolute, &root);
882 };
883 layout
884 .package_roots
885 .iter()
886 .any(|root| conventional_rust_source(&absolute, root))
887 || layout
888 .target_sources
889 .iter()
890 .any(|source| target_contains_source(source, &absolute))
891}
892
893pub fn cargo_workspace_root(path: &Path) -> Option<PathBuf> {
896 let root = find_cargo_root(path).ok()?;
897 cargo_source_layout(&root).map(|layout| layout.workspace_root)
898}
899
900fn cargo_source_layout(root: &Path) -> Option<CargoSourceLayout> {
901 let cache = CARGO_SOURCE_LAYOUTS.get_or_init(|| Mutex::new(HashMap::new()));
902 if let Some(layout) = cache.lock().ok()?.get(root).cloned() {
903 return layout;
904 }
905
906 let output = Command::new("cargo")
907 .args(["metadata", "--no-deps", "--format-version", "1"])
908 .current_dir(root)
909 .output()
910 .ok();
911 let layout = output
912 .filter(|output| output.status.success())
913 .and_then(|output| serde_json::from_slice::<serde_json::Value>(&output.stdout).ok())
914 .and_then(|metadata| {
915 let workspace_root = metadata
916 .get("workspace_root")?
917 .as_str()
918 .map(PathBuf::from)?;
919 let packages = metadata.get("packages")?.as_array()?;
920 let package_roots = packages
921 .iter()
922 .filter_map(|package| package.get("manifest_path"))
923 .filter_map(serde_json::Value::as_str)
924 .filter_map(|manifest| Path::new(manifest).parent())
925 .map(Path::to_path_buf)
926 .collect();
927 let target_sources = packages
928 .iter()
929 .flat_map(|package| {
930 package
931 .get("targets")
932 .and_then(serde_json::Value::as_array)
933 .into_iter()
934 .flatten()
935 })
936 .filter_map(|target| target.get("src_path"))
937 .filter_map(serde_json::Value::as_str)
938 .map(PathBuf::from)
939 .collect();
940 Some(CargoSourceLayout {
941 workspace_root,
942 package_roots,
943 target_sources,
944 })
945 });
946 if let Ok(mut cache) = cache.lock() {
947 cache.insert(root.to_path_buf(), layout.clone());
948 }
949 layout
950}
951
952fn conventional_rust_source(file: &Path, package_root: &Path) -> bool {
953 file.starts_with(package_root.join("src"))
954}
955
956fn target_contains_source(target: &Path, file: &Path) -> bool {
957 if target == file {
958 return true;
959 }
960 let Some(parent) = target.parent() else {
961 return false;
962 };
963 match target.file_name().and_then(|name| name.to_str()) {
964 Some("lib.rs" | "main.rs" | "mod.rs") => file.starts_with(parent),
965 _ => target
966 .file_stem()
967 .map(|stem| file.starts_with(parent.join(stem)))
968 .unwrap_or(false),
969 }
970}
971
972fn find_ocaml_root(path: &Path) -> PathBuf {
973 let start = if path.is_dir() {
974 path
975 } else {
976 path.parent().unwrap_or_else(|| Path::new("."))
977 };
978 for ancestor in start.ancestors() {
979 if ancestor.join("dune-project").is_file() {
980 return ancestor.to_path_buf();
981 }
982 }
983 start.to_path_buf()
984}
985
986fn module_path(root: &Path, file: &Path) -> Vec<String> {
987 let relative = file.strip_prefix(root).unwrap_or(file);
988 let mut parts = relative
989 .components()
990 .filter_map(|component| match component {
991 Component::Normal(value) => value.to_str().map(ToOwned::to_owned),
992 _ => None,
993 })
994 .collect::<Vec<_>>();
995
996 if parts.first().is_some_and(|part| part == "src") {
997 parts.remove(0);
998 }
999 if let Some(last) = parts.last_mut() {
1000 *last = Path::new(last)
1001 .file_stem()
1002 .and_then(|stem| stem.to_str())
1003 .unwrap_or(last)
1004 .to_owned();
1005 }
1006 if parts
1007 .last()
1008 .is_some_and(|part| matches!(part.as_str(), "lib" | "main" | "mod"))
1009 {
1010 parts.pop();
1011 }
1012 parts
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018 use crate::model::{
1019 CallLabel, CallSite, CallSiteId, CallSyntax, CallTarget, FileAnalysis, FunctionInfo,
1020 LanguageId, SourceSpan,
1021 };
1022
1023 #[test]
1024 fn cargo_test_targets_are_project_sources() {
1025 let test = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli.rs");
1026 assert!(rust_file_is_project_source(&test));
1027 assert_eq!(
1028 cargo_workspace_root(&test),
1029 Path::new(env!("CARGO_MANIFEST_DIR")).canonicalize().ok()
1030 );
1031 }
1032
1033 #[test]
1034 fn deep_shared_dag_is_diffed_without_materializing_an_exponential_tree() {
1035 let before = shared_dag_graph(false);
1036 let after = shared_dag_graph(true);
1037 let trees = changed_entries(&before, &after, [test_symbol("f0")], 2);
1038
1039 assert_eq!(trees.len(), 1);
1040 assert!(diff_node_count(&trees[0].tree) < 50);
1041 assert!(diff_has_label(&trees[0].tree, "… changed below max depth"));
1042 }
1043
1044 #[test]
1045 fn unresolved_edges_are_indexed_in_large_unchanged_graphs() {
1046 let graph = unresolved_graph(4_000);
1047 let trees = changed_entries(&graph, &graph, [test_symbol("f0")], 2);
1048 assert!(trees.is_empty());
1049 }
1050
1051 fn shared_dag_graph(changed: bool) -> ProgramGraph {
1052 const LEVELS: usize = 35;
1053 let mut functions = (0..=LEVELS)
1054 .map(|level| {
1055 let name = format!("f{level}");
1056 let calls = if level < LEVELS {
1057 (0..2)
1058 .map(|occurrence| test_call(&name, occurrence, &format!("f{}", level + 1)))
1059 .collect()
1060 } else if changed {
1061 vec![test_call(&name, 0, "audit")]
1062 } else {
1063 Vec::new()
1064 };
1065 test_function(&name, level + 1, calls)
1066 })
1067 .collect::<Vec<_>>();
1068 functions.push(test_function("audit", LEVELS + 2, Vec::new()));
1069 ProgramGraph::from_files([FileAnalysis {
1070 functions,
1071 ..FileAnalysis::default()
1072 }])
1073 .unwrap()
1074 }
1075
1076 fn unresolved_graph(size: usize) -> ProgramGraph {
1077 let functions = (0..size)
1078 .map(|index| {
1079 let name = format!("f{index}");
1080 test_function(
1081 &name,
1082 index + 1,
1083 vec![CallSite {
1084 id: CallSiteId(format!("{name}:external")),
1085 syntax: CallSyntax::Path(vec!["external".to_owned()]),
1086 target: CallTarget::Unresolved,
1087 label: CallLabel::new("external()"),
1088 span: test_span(index + 1),
1089 }],
1090 )
1091 })
1092 .collect();
1093 ProgramGraph::from_files([FileAnalysis {
1094 functions,
1095 ..FileAnalysis::default()
1096 }])
1097 .unwrap()
1098 }
1099
1100 fn test_symbol(name: &str) -> SymbolId {
1101 SymbolId {
1102 language: LanguageId::new("rust"),
1103 module: Vec::new(),
1104 container: None,
1105 name: name.to_owned(),
1106 }
1107 }
1108
1109 fn test_function(name: &str, line: usize, calls: Vec<CallSite>) -> FunctionInfo {
1110 FunctionInfo {
1111 id: test_symbol(name),
1112 label: CallLabel::new(format!("{name}()")),
1113 public: false,
1114 calls,
1115 span: test_span(line),
1116 }
1117 }
1118
1119 fn test_call(caller: &str, occurrence: usize, target: &str) -> CallSite {
1120 CallSite {
1121 id: CallSiteId(format!("{caller}:{occurrence}")),
1122 syntax: CallSyntax::Path(vec![target.to_owned()]),
1123 target: CallTarget::Direct(test_symbol(target)),
1124 label: CallLabel::new(format!("{target}()")),
1125 span: test_span(occurrence + 1),
1126 }
1127 }
1128
1129 fn test_span(line: usize) -> crate::model::SourceSpan {
1130 SourceSpan {
1131 file: PathBuf::from("fixture.rs"),
1132 start_line: line,
1133 start_column: 0,
1134 start_byte: None,
1135 end_line: line,
1136 end_column: 1,
1137 end_byte: None,
1138 }
1139 }
1140
1141 fn diff_node_count(node: &DiffNode) -> usize {
1142 1 + node.children.iter().map(diff_node_count).sum::<usize>()
1143 }
1144
1145 fn diff_has_label(node: &DiffNode, label: &str) -> bool {
1146 node.label.default == label
1147 || node
1148 .children
1149 .iter()
1150 .any(|child| diff_has_label(child, label))
1151 }
1152}