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