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