Skip to main content

diffkit/
engine.rs

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
842/// Return whether Cargo can plausibly compile `path` as part of one of the
843/// package targets in the surrounding workspace. Merely living somewhere
844/// below a Cargo.toml is not enough (documentation snippets are a common
845/// counterexample).
846pub 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}