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, 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
871/// Return whether Cargo can plausibly compile `path` as part of one of the
872/// package targets in the surrounding workspace. Merely living somewhere
873/// below a Cargo.toml is not enough (documentation snippets are a common
874/// counterexample).
875pub 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
893/// Resolve Cargo's workspace root while sharing the metadata query used for
894/// source-target membership checks.
895pub 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}