Skip to main content

fallow_engine/
module_graph.rs

1//! Module graph contracts owned by the engine boundary.
2
3#![allow(
4    clippy::implicit_hasher,
5    reason = "engine graph helpers use FxHashSet changed-file sets consistently with the rest of fallow"
6)]
7
8use std::path::{Path, PathBuf};
9
10use fallow_types::discover::FileId;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13use fallow_graph::graph::{
14    CoordinationGapPaths as GraphCoordinationGapPaths,
15    FocusFileFactsPaths as GraphFocusFileFactsPaths, ImpactClosurePaths as GraphImpactClosurePaths,
16    ModuleGraph, PartitionOrderPaths as GraphPartitionOrderPaths,
17    ReviewUnitPaths as GraphReviewUnitPaths,
18};
19use fallow_graph::graph::{
20    DirectImporterSummary as GraphDirectImporterSummary,
21    ImportedSymbolSummary as GraphImportedSymbolSummary, ModuleNode,
22};
23
24/// Engine-owned retained graph handle.
25///
26/// Downstream crates can request stable graph facts through engine helpers
27/// without depending on `fallow-graph` node internals.
28#[derive(Debug)]
29pub struct RetainedModuleGraph {
30    inner: ModuleGraph,
31}
32
33impl RetainedModuleGraph {
34    /// Wrap a freshly built module graph for engine result contracts.
35    #[must_use]
36    pub(crate) const fn new(inner: ModuleGraph) -> Self {
37        Self { inner }
38    }
39
40    pub(crate) const fn as_graph(&self) -> &ModuleGraph {
41        &self.inner
42    }
43
44    /// Number of modules in the retained graph.
45    #[must_use]
46    pub fn module_count(&self) -> usize {
47        self.inner.module_count()
48    }
49
50    /// Number of edges in the retained graph.
51    #[must_use]
52    pub fn edge_count(&self) -> usize {
53        self.inner.edge_count()
54    }
55
56    /// Build public export keys for a precomputed public-entry set.
57    #[must_use]
58    pub fn public_export_keys(
59        &self,
60        public_entries: &FxHashSet<FileId>,
61        root: &Path,
62    ) -> FxHashSet<String> {
63        self.inner.public_export_keys(public_entries, root)
64    }
65
66    /// Count direct importer modules for one file id.
67    #[must_use]
68    pub fn direct_importer_count(&self, file_id: FileId) -> usize {
69        self.inner
70            .reverse_deps
71            .get(file_id.0 as usize)
72            .map_or(0, Vec::len)
73    }
74
75    /// Summaries for modules that directly import one file.
76    #[must_use]
77    pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
78        self.inner
79            .direct_importer_summaries(target)
80            .into_iter()
81            .map(DirectImporterSummary::from)
82            .collect()
83    }
84}
85
86impl From<ModuleGraph> for RetainedModuleGraph {
87    fn from(inner: ModuleGraph) -> Self {
88        Self::new(inner)
89    }
90}
91
92/// Engine-owned importer details for one file that directly imports a target module.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct DirectImporterSummary {
95    pub source: FileId,
96    pub symbols: Vec<ImportedSymbolSummary>,
97}
98
99impl From<GraphDirectImporterSummary> for DirectImporterSummary {
100    fn from(summary: GraphDirectImporterSummary) -> Self {
101        Self {
102            source: summary.source,
103            symbols: summary.symbols.into_iter().map(Into::into).collect(),
104        }
105    }
106}
107
108/// Engine-owned symbol details for a direct import edge.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ImportedSymbolSummary {
111    pub imported: String,
112    pub local: String,
113    pub type_only: bool,
114}
115
116impl From<GraphImportedSymbolSummary> for ImportedSymbolSummary {
117    fn from(symbol: GraphImportedSymbolSummary) -> Self {
118        Self {
119            imported: symbol.imported,
120            local: symbol.local,
121            type_only: symbol.type_only,
122        }
123    }
124}
125
126/// Engine-owned snapshot of one value export in a module graph.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ModuleValueExport {
129    pub file_id: FileId,
130    pub name: String,
131    pub span_start: u32,
132    pub test_referenced: bool,
133}
134
135/// Engine-owned impact closure with file ids resolved to paths.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct ImpactClosurePaths {
138    pub in_diff: Vec<String>,
139    pub affected_not_shown: Vec<String>,
140    pub coordination_gap: Vec<CoordinationGapPaths>,
141}
142
143impl From<GraphImpactClosurePaths> for ImpactClosurePaths {
144    fn from(paths: GraphImpactClosurePaths) -> Self {
145        Self {
146            in_diff: paths.in_diff,
147            affected_not_shown: paths.affected_not_shown,
148            coordination_gap: paths
149                .coordination_gap
150                .into_iter()
151                .map(CoordinationGapPaths::from)
152                .collect(),
153        }
154    }
155}
156
157/// Engine-owned coordination gap between a changed contract and consumer.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct CoordinationGapPaths {
160    pub changed_file: String,
161    pub consumer_file: String,
162    pub consumed_symbols: Vec<String>,
163}
164
165impl From<GraphCoordinationGapPaths> for CoordinationGapPaths {
166    fn from(paths: GraphCoordinationGapPaths) -> Self {
167        Self {
168            changed_file: paths.changed_file,
169            consumer_file: paths.consumer_file,
170            consumed_symbols: paths.consumed_symbols,
171        }
172    }
173}
174
175/// Engine-owned review partition and dependency-sensible order.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub struct PartitionOrderPaths {
178    pub units: Vec<ReviewUnitPaths>,
179    pub order: Vec<String>,
180}
181
182impl From<GraphPartitionOrderPaths> for PartitionOrderPaths {
183    fn from(paths: GraphPartitionOrderPaths) -> Self {
184        Self {
185            units: paths.units.into_iter().map(ReviewUnitPaths::from).collect(),
186            order: paths.order,
187        }
188    }
189}
190
191/// Engine-owned changed-file review unit.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ReviewUnitPaths {
194    pub module_dir: String,
195    pub files: Vec<String>,
196}
197
198impl From<GraphReviewUnitPaths> for ReviewUnitPaths {
199    fn from(paths: GraphReviewUnitPaths) -> Self {
200        Self {
201            module_dir: paths.module_dir,
202            files: paths.files,
203        }
204    }
205}
206
207/// Engine-owned focus facts for one changed file.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct FocusFileFactsPaths {
210    pub file: String,
211    pub fan_in: u32,
212    pub fan_out: u32,
213    pub dynamic_dispatch: bool,
214    pub re_export_indirection: bool,
215}
216
217impl From<GraphFocusFileFactsPaths> for FocusFileFactsPaths {
218    fn from(paths: GraphFocusFileFactsPaths) -> Self {
219        Self {
220            file: paths.file,
221            fan_in: paths.fan_in,
222            fan_out: paths.fan_out,
223            dynamic_dispatch: paths.dynamic_dispatch,
224            re_export_indirection: paths.re_export_indirection,
225        }
226    }
227}
228
229/// Return value exports with test-reference state without exposing graph node
230/// internals to downstream crates.
231#[must_use]
232pub fn module_value_exports(graph: &RetainedModuleGraph) -> Vec<ModuleValueExport> {
233    let graph = graph.as_graph();
234    let is_test_reachable = |file_id: FileId| {
235        graph
236            .modules
237            .get(file_id.0 as usize)
238            .is_some_and(ModuleNode::is_test_reachable)
239    };
240
241    graph
242        .modules
243        .iter()
244        .flat_map(|node| {
245            node.exports
246                .iter()
247                .filter(|export| !export.is_type_only)
248                .map(|export| ModuleValueExport {
249                    file_id: node.file_id,
250                    name: export.name.to_string(),
251                    span_start: export.span.start,
252                    test_referenced: export
253                        .references
254                        .iter()
255                        .any(|reference| is_test_reachable(reference.from_file)),
256                })
257        })
258        .collect()
259}
260
261/// Compute a path-resolved impact closure for absolute changed paths.
262#[must_use]
263pub fn impact_closure_for_changed_paths(
264    graph: &RetainedModuleGraph,
265    root: &Path,
266    changed_files: &FxHashSet<PathBuf>,
267) -> Option<ImpactClosurePaths> {
268    let graph = graph.as_graph();
269    let changed_ids = changed_file_ids(graph, changed_files);
270    if changed_ids.is_empty() {
271        return None;
272    }
273
274    let closure = graph.impact_closure(&changed_ids);
275    Some(graph.closure_with_paths(&closure, root).into())
276}
277
278/// Compute path-resolved partition order for absolute changed paths.
279#[must_use]
280pub fn partition_order_for_changed_paths(
281    graph: &RetainedModuleGraph,
282    root: &Path,
283    changed_files: &FxHashSet<PathBuf>,
284) -> Option<PartitionOrderPaths> {
285    let graph = graph.as_graph();
286    let changed_ids = changed_file_ids(graph, changed_files);
287    if changed_ids.is_empty() {
288        return None;
289    }
290
291    let partition = graph.partition_order(&changed_ids);
292    Some(graph.partition_order_with_paths(&partition, root).into())
293}
294
295/// Compute path-resolved focus graph facts for absolute changed paths.
296#[must_use]
297pub fn focus_facts_for_changed_paths(
298    graph: &RetainedModuleGraph,
299    root: &Path,
300    changed_files: &FxHashSet<PathBuf>,
301) -> Option<Vec<FocusFileFactsPaths>> {
302    let graph = graph.as_graph();
303    let changed_ids = changed_file_ids(graph, changed_files);
304    if changed_ids.is_empty() {
305        return None;
306    }
307
308    let facts = graph.focus_file_facts(&changed_ids);
309    Some(
310        graph
311            .focus_facts_with_paths(&facts, root)
312            .into_iter()
313            .map(FocusFileFactsPaths::from)
314            .collect(),
315    )
316}
317
318/// Compute changed-file export line anchors without exposing graph nodes.
319#[must_use]
320pub fn export_lines_for_changed_paths(
321    graph: &RetainedModuleGraph,
322    root: &Path,
323    changed_files: &FxHashSet<PathBuf>,
324) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
325    let graph = graph.as_graph();
326    let changed_norm = normalized_changed_paths(changed_files);
327    let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
328    for module in &graph.modules {
329        let abs = normalize_path(&module.path);
330        if !changed_norm.contains(&abs) || module.exports.is_empty() {
331            continue;
332        }
333        let Ok(content) = std::fs::read_to_string(&module.path) else {
334            continue;
335        };
336        let offsets = fallow_types::extract::compute_line_offsets(&content);
337        let exports: Vec<(String, u32)> = module
338            .exports
339            .iter()
340            .map(|export| {
341                let (line, _) =
342                    fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
343                (export.name.to_string(), line)
344            })
345            .collect();
346        map.insert(relative_key_path(&module.path, root), exports);
347    }
348    Some(map)
349}
350
351/// Compute direct non-diff internal consumer counts for absolute changed paths.
352#[must_use]
353pub fn internal_consumers_for_changed_paths(
354    graph: &RetainedModuleGraph,
355    root: &Path,
356    changed_files: &FxHashSet<PathBuf>,
357) -> Option<FxHashMap<String, u64>> {
358    let graph = graph.as_graph();
359    let changed_norm = normalized_changed_paths(changed_files);
360    let id_to_norm: FxHashMap<FileId, String> = graph
361        .modules
362        .iter()
363        .map(|module| (module.file_id, normalize_path(&module.path)))
364        .collect();
365
366    let mut map: FxHashMap<String, u64> = FxHashMap::default();
367    for module in &graph.modules {
368        let abs = normalize_path(&module.path);
369        if !changed_norm.contains(&abs) {
370            continue;
371        }
372        let count = graph
373            .importers_of(module.file_id)
374            .iter()
375            .filter(|imp| {
376                id_to_norm
377                    .get(imp)
378                    .is_none_or(|p| !changed_norm.contains(p))
379            })
380            .count() as u64;
381        map.insert(relative_key_path(&module.path, root), count);
382    }
383    Some(map)
384}
385
386fn changed_file_ids(graph: &ModuleGraph, changed_files: &FxHashSet<PathBuf>) -> Vec<FileId> {
387    let path_to_id: FxHashMap<String, FileId> = graph
388        .modules
389        .iter()
390        .map(|module| (normalize_path(&module.path), module.file_id))
391        .collect();
392
393    changed_files
394        .iter()
395        .filter_map(|path| path_to_id.get(&normalize_path(path)).copied())
396        .collect()
397}
398
399fn normalized_changed_paths(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<String> {
400    changed_files
401        .iter()
402        .map(|path| normalize_path(path))
403        .collect()
404}
405
406fn normalize_path(path: &Path) -> String {
407    path.to_string_lossy().replace('\\', "/")
408}
409
410fn relative_key_path(path: &Path, root: &Path) -> String {
411    let simple_path = dunce::simplified(path);
412    let simple_root = dunce::simplified(root);
413    simple_path
414        .strip_prefix(simple_root)
415        .unwrap_or(simple_path)
416        .to_string_lossy()
417        .replace('\\', "/")
418}