1#![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#[derive(Debug)]
29pub struct RetainedModuleGraph {
30 inner: ModuleGraph,
31}
32
33impl RetainedModuleGraph {
34 #[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 #[must_use]
46 pub fn module_count(&self) -> usize {
47 self.inner.module_count()
48 }
49
50 #[must_use]
52 pub fn edge_count(&self) -> usize {
53 self.inner.edge_count()
54 }
55
56 #[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 #[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 #[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}