1use std::path::Path;
20
21use super::relativize;
22
23use fallow_types::discover::FileId;
24use rustc_hash::FxHashSet;
25
26use super::{ModuleGraph, ReferenceKind};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct FocusFileFacts {
37 file: FileId,
39 fan_in: u32,
42 fan_out: u32,
45 dynamic_dispatch: bool,
52 re_export_indirection: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct FocusFileFactsPaths {
63 pub file: String,
65 pub fan_in: u32,
67 pub fan_out: u32,
69 pub dynamic_dispatch: bool,
71 pub re_export_indirection: bool,
73}
74
75struct ReferenceSignalSets {
77 dynamic_targets: FxHashSet<FileId>,
79 re_export_ref_targets: FxHashSet<FileId>,
81 dynamic_sources: FxHashSet<FileId>,
83 re_export_sources: FxHashSet<FileId>,
85}
86
87impl ModuleGraph {
88 #[must_use]
95 pub fn focus_file_facts(&self, changed: &[FileId]) -> Vec<FocusFileFacts> {
96 let mut seen = FxHashSet::default();
98 let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
99 for &id in changed {
100 if (id.0 as usize) < self.modules.len() && seen.insert(id) {
101 changed_ids.push(id);
102 }
103 }
104 changed_ids.sort_unstable_by_key(|f| f.0);
105
106 let reference_signals = self.collect_reference_signal_sets();
110
111 changed_ids
112 .iter()
113 .map(|&id| {
114 let fan_in = self.fan_in_count(id);
115 let fan_out = self.fan_out_count(id);
116 let dynamic_dispatch = reference_signals.dynamic_targets.contains(&id)
117 || reference_signals.dynamic_sources.contains(&id);
118 let re_export_indirection = self.is_re_export_participant(id, &reference_signals);
119 FocusFileFacts {
120 file: id,
121 fan_in,
122 fan_out,
123 dynamic_dispatch,
124 re_export_indirection,
125 }
126 })
127 .collect()
128 }
129
130 fn fan_in_count(&self, file: FileId) -> u32 {
132 let Some(importers) = self.reverse_deps.get(file.0 as usize) else {
133 return 0;
134 };
135 let mut distinct: FxHashSet<FileId> = FxHashSet::default();
136 for &importer in importers {
137 if importer != file {
138 distinct.insert(importer);
139 }
140 }
141 u32::try_from(distinct.len()).unwrap_or(u32::MAX)
142 }
143
144 fn fan_out_count(&self, file: FileId) -> u32 {
147 let mut distinct: FxHashSet<FileId> = FxHashSet::default();
148 for target in self.edges_for(file) {
149 if target != file {
150 distinct.insert(target);
151 }
152 }
153 u32::try_from(distinct.len()).unwrap_or(u32::MAX)
154 }
155
156 fn collect_reference_signal_sets(&self) -> ReferenceSignalSets {
158 let mut dynamic_targets: FxHashSet<FileId> = FxHashSet::default();
159 let mut re_export_ref_targets: FxHashSet<FileId> = FxHashSet::default();
160 let mut dynamic_sources: FxHashSet<FileId> = FxHashSet::default();
161 let mut re_export_sources: FxHashSet<FileId> = FxHashSet::default();
162 for node in &self.modules {
163 for edge in &node.re_exports {
164 re_export_sources.insert(edge.source_file);
165 }
166 for export in &node.exports {
167 for reference in &export.references {
168 match reference.kind {
169 ReferenceKind::DynamicImport => {
170 dynamic_targets.insert(node.file_id);
171 dynamic_sources.insert(reference.from_file);
172 }
173 ReferenceKind::ReExport => {
174 re_export_ref_targets.insert(node.file_id);
175 }
176 _ => {}
177 }
178 }
179 }
180 }
181 ReferenceSignalSets {
182 dynamic_targets,
183 re_export_ref_targets,
184 dynamic_sources,
185 re_export_sources,
186 }
187 }
188
189 fn is_re_export_participant(&self, file: FileId, sets: &ReferenceSignalSets) -> bool {
194 if sets.re_export_ref_targets.contains(&file) {
195 return true;
196 }
197 if let Some(node) = self.modules.get(file.0 as usize)
199 && !node.re_exports.is_empty()
200 {
201 return true;
202 }
203 sets.re_export_sources.contains(&file)
205 }
206
207 #[must_use]
211 pub fn focus_facts_with_paths(
212 &self,
213 facts: &[FocusFileFacts],
214 root: &Path,
215 ) -> Vec<FocusFileFactsPaths> {
216 let mut resolved: Vec<FocusFileFactsPaths> = facts
217 .iter()
218 .filter_map(|f| {
219 let path = self.modules.get(f.file.0 as usize)?;
220 Some(FocusFileFactsPaths {
221 file: relativize(&path.path, root),
222 fan_in: f.fan_in,
223 fan_out: f.fan_out,
224 dynamic_dispatch: f.dynamic_dispatch,
225 re_export_indirection: f.re_export_indirection,
226 })
227 })
228 .collect();
229 resolved.sort_by(|a, b| a.file.cmp(&b.file));
230 resolved
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
238 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
239 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
240 use std::path::PathBuf;
241
242 fn file(id: u32, path: &str) -> DiscoveredFile {
243 DiscoveredFile {
244 id: FileId(id),
245 path: PathBuf::from(path),
246 size_bytes: 10,
247 }
248 }
249
250 fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
251 ResolvedImport {
252 info: ImportInfo {
253 source: source.to_string(),
254 imported_name: ImportedName::Named(name.to_string()),
255 local_name: name.to_string(),
256 is_type_only: false,
257 is_type_only_star: false,
258 from_style: false,
259 span: oxc_span::Span::new(0, 10),
260 source_span: oxc_span::Span::default(),
261 },
262 target: ResolveResult::InternalModule(target),
263 }
264 }
265
266 fn named_export(name: &str) -> ExportInfo {
267 ExportInfo {
268 name: ExportName::Named(name.to_string()),
269 local_name: Some(name.to_string()),
270 is_type_only: false,
271 visibility: VisibilityTag::None,
272 expected_unused_reason: None,
273 span: oxc_span::Span::new(0, 20),
274 members: vec![],
275 is_side_effect_used: false,
276 super_class: None,
277 }
278 }
279
280 fn build_chain_graph() -> ModuleGraph {
282 let files = vec![
283 file(0, "/p/src/core.ts"),
284 file(1, "/p/src/mid.ts"),
285 file(2, "/p/src/app.ts"),
286 ];
287 let entry_points = vec![EntryPoint {
288 path: PathBuf::from("/p/src/app.ts"),
289 source: EntryPointSource::PackageJsonMain,
290 }];
291 let resolved = vec![
292 ResolvedModule {
293 file_id: FileId(0),
294 path: PathBuf::from("/p/src/core.ts"),
295 exports: vec![named_export("compute")].into(),
296 ..Default::default()
297 },
298 ResolvedModule {
299 file_id: FileId(1),
300 path: PathBuf::from("/p/src/mid.ts"),
301 resolved_imports: vec![named_import("./core", "compute", FileId(0))],
302 exports: vec![named_export("midFn")].into(),
303 ..Default::default()
304 },
305 ResolvedModule {
306 file_id: FileId(2),
307 path: PathBuf::from("/p/src/app.ts"),
308 resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
309 ..Default::default()
310 },
311 ];
312 ModuleGraph::build(&resolved, &entry_points, &files)
313 }
314
315 #[test]
316 fn fan_in_counts_importers() {
317 let graph = build_chain_graph();
318 let facts = graph.focus_file_facts(&[FileId(0)]);
320 assert_eq!(facts.len(), 1);
321 assert_eq!(facts[0].fan_in, 1);
322 assert_eq!(facts[0].fan_out, 0);
323 }
324
325 #[test]
326 fn fan_out_counts_forward_deps() {
327 let graph = build_chain_graph();
328 let facts = graph.focus_file_facts(&[FileId(2)]);
330 assert_eq!(facts.len(), 1);
331 assert_eq!(facts[0].fan_out, 1);
332 assert_eq!(facts[0].fan_in, 0);
333 }
334
335 #[test]
336 fn focus_facts_are_byte_identical_across_runs() {
337 let graph = build_chain_graph();
338 let changed = [FileId(0), FileId(1), FileId(2)];
339 let first = graph.focus_file_facts(&changed);
340 let second = graph.focus_file_facts(&changed);
341 assert_eq!(first, second);
342 let p1 = graph.focus_facts_with_paths(&first, Path::new("/p"));
343 let p2 = graph.focus_facts_with_paths(&second, Path::new("/p"));
344 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
345 }
346
347 #[test]
348 fn re_export_barrel_flags_indirection() {
349 use crate::resolve::ResolvedReExport;
350 use fallow_types::extract::ReExportInfo;
351
352 let files = vec![
353 file(0, "/p/src/impl.ts"),
354 file(1, "/p/src/barrel.ts"),
355 file(2, "/p/src/consumer.ts"),
356 ];
357 let entry_points = vec![EntryPoint {
358 path: PathBuf::from("/p/src/consumer.ts"),
359 source: EntryPointSource::PackageJsonMain,
360 }];
361 let resolved = vec![
362 ResolvedModule {
363 file_id: FileId(0),
364 path: PathBuf::from("/p/src/impl.ts"),
365 exports: vec![named_export("widget")].into(),
366 ..Default::default()
367 },
368 ResolvedModule {
369 file_id: FileId(1),
370 path: PathBuf::from("/p/src/barrel.ts"),
371 re_exports: vec![ResolvedReExport {
372 info: ReExportInfo {
373 source: "./impl".to_string(),
374 imported_name: "widget".to_string(),
375 exported_name: "widget".to_string(),
376 is_type_only: false,
377 span: oxc_span::Span::new(0, 10),
378 statement_span: oxc_span::Span::new(0, 0),
379 source_span: oxc_span::Span::new(0, 0),
380 },
381 target: ResolveResult::InternalModule(FileId(0)),
382 }],
383 ..Default::default()
384 },
385 ResolvedModule {
386 file_id: FileId(2),
387 path: PathBuf::from("/p/src/consumer.ts"),
388 resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
389 ..Default::default()
390 },
391 ];
392 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
393 let barrel = graph.focus_file_facts(&[FileId(1)]);
396 assert!(barrel[0].re_export_indirection, "barrel flags indirection");
397 let impl_facts = graph.focus_file_facts(&[FileId(0)]);
398 assert!(
399 impl_facts[0].re_export_indirection,
400 "re-export source flags indirection"
401 );
402 }
403
404 #[test]
405 fn empty_changed_set_yields_no_facts() {
406 let graph = build_chain_graph();
407 assert!(graph.focus_file_facts(&[]).is_empty());
408 }
409
410 #[test]
411 fn out_of_range_ids_are_dropped() {
412 let graph = build_chain_graph();
413 let facts = graph.focus_file_facts(&[FileId(999)]);
414 assert!(facts.is_empty());
415 }
416}