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 deprecated: false,
278 deprecated_reason: None,
279 }
280 }
281
282 fn build_chain_graph() -> ModuleGraph {
284 let files = vec![
285 file(0, "/p/src/core.ts"),
286 file(1, "/p/src/mid.ts"),
287 file(2, "/p/src/app.ts"),
288 ];
289 let entry_points = vec![EntryPoint {
290 path: PathBuf::from("/p/src/app.ts"),
291 source: EntryPointSource::PackageJsonMain,
292 }];
293 let resolved = vec![
294 ResolvedModule {
295 file_id: FileId(0),
296 path: PathBuf::from("/p/src/core.ts"),
297 exports: vec![named_export("compute")].into(),
298 ..Default::default()
299 },
300 ResolvedModule {
301 file_id: FileId(1),
302 path: PathBuf::from("/p/src/mid.ts"),
303 resolved_imports: vec![named_import("./core", "compute", FileId(0))],
304 exports: vec![named_export("midFn")].into(),
305 ..Default::default()
306 },
307 ResolvedModule {
308 file_id: FileId(2),
309 path: PathBuf::from("/p/src/app.ts"),
310 resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
311 ..Default::default()
312 },
313 ];
314 ModuleGraph::build(&resolved, &entry_points, &files)
315 }
316
317 #[test]
318 fn fan_in_counts_importers() {
319 let graph = build_chain_graph();
320 let facts = graph.focus_file_facts(&[FileId(0)]);
322 assert_eq!(facts.len(), 1);
323 assert_eq!(facts[0].fan_in, 1);
324 assert_eq!(facts[0].fan_out, 0);
325 }
326
327 #[test]
328 fn fan_out_counts_forward_deps() {
329 let graph = build_chain_graph();
330 let facts = graph.focus_file_facts(&[FileId(2)]);
332 assert_eq!(facts.len(), 1);
333 assert_eq!(facts[0].fan_out, 1);
334 assert_eq!(facts[0].fan_in, 0);
335 }
336
337 #[test]
338 fn focus_facts_are_byte_identical_across_runs() {
339 let graph = build_chain_graph();
340 let changed = [FileId(0), FileId(1), FileId(2)];
341 let first = graph.focus_file_facts(&changed);
342 let second = graph.focus_file_facts(&changed);
343 assert_eq!(first, second);
344 let p1 = graph.focus_facts_with_paths(&first, Path::new("/p"));
345 let p2 = graph.focus_facts_with_paths(&second, Path::new("/p"));
346 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
347 }
348
349 #[test]
350 fn re_export_barrel_flags_indirection() {
351 use crate::resolve::ResolvedReExport;
352 use fallow_types::extract::ReExportInfo;
353
354 let files = vec![
355 file(0, "/p/src/impl.ts"),
356 file(1, "/p/src/barrel.ts"),
357 file(2, "/p/src/consumer.ts"),
358 ];
359 let entry_points = vec![EntryPoint {
360 path: PathBuf::from("/p/src/consumer.ts"),
361 source: EntryPointSource::PackageJsonMain,
362 }];
363 let resolved = vec![
364 ResolvedModule {
365 file_id: FileId(0),
366 path: PathBuf::from("/p/src/impl.ts"),
367 exports: vec![named_export("widget")].into(),
368 ..Default::default()
369 },
370 ResolvedModule {
371 file_id: FileId(1),
372 path: PathBuf::from("/p/src/barrel.ts"),
373 re_exports: vec![ResolvedReExport {
374 info: ReExportInfo {
375 source: "./impl".to_string(),
376 imported_name: "widget".to_string(),
377 exported_name: "widget".to_string(),
378 is_type_only: false,
379 span: oxc_span::Span::new(0, 10),
380 statement_span: oxc_span::Span::new(0, 0),
381 source_span: oxc_span::Span::new(0, 0),
382 },
383 target: ResolveResult::InternalModule(FileId(0)),
384 }],
385 ..Default::default()
386 },
387 ResolvedModule {
388 file_id: FileId(2),
389 path: PathBuf::from("/p/src/consumer.ts"),
390 resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
391 ..Default::default()
392 },
393 ];
394 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
395 let barrel = graph.focus_file_facts(&[FileId(1)]);
398 assert!(barrel[0].re_export_indirection, "barrel flags indirection");
399 let impl_facts = graph.focus_file_facts(&[FileId(0)]);
400 assert!(
401 impl_facts[0].re_export_indirection,
402 "re-export source flags indirection"
403 );
404 }
405
406 #[test]
407 fn empty_changed_set_yields_no_facts() {
408 let graph = build_chain_graph();
409 assert!(graph.focus_file_facts(&[]).is_empty());
410 }
411
412 #[test]
413 fn out_of_range_ids_are_dropped() {
414 let graph = build_chain_graph();
415 let facts = graph.focus_file_facts(&[FileId(999)]);
416 assert!(facts.is_empty());
417 }
418}