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 pub file: FileId,
37 pub fan_in: u32,
40 pub fan_out: u32,
43 pub dynamic_dispatch: bool,
50 pub 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
73impl ModuleGraph {
74 #[must_use]
81 pub fn focus_file_facts(&self, changed: &[FileId]) -> Vec<FocusFileFacts> {
82 let mut seen = FxHashSet::default();
84 let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
85 for &id in changed {
86 if (id.0 as usize) < self.modules.len() && seen.insert(id) {
87 changed_ids.push(id);
88 }
89 }
90 changed_ids.sort_unstable_by_key(|f| f.0);
91
92 let (dynamic_targets, re_export_ref_targets) = self.collect_reference_signal_targets();
97
98 changed_ids
99 .iter()
100 .map(|&id| {
101 let fan_in = self.fan_in_count(id);
102 let fan_out = self.fan_out_count(id);
103 let dynamic_dispatch =
104 dynamic_targets.contains(&id) || self.has_dynamic_outgoing_edge(id);
105 let re_export_indirection = re_export_ref_targets.contains(&id)
106 || self.is_re_export_participant(id, &re_export_ref_targets);
107 FocusFileFacts {
108 file: id,
109 fan_in,
110 fan_out,
111 dynamic_dispatch,
112 re_export_indirection,
113 }
114 })
115 .collect()
116 }
117
118 fn fan_in_count(&self, file: FileId) -> u32 {
120 let Some(importers) = self.reverse_deps.get(file.0 as usize) else {
121 return 0;
122 };
123 let mut distinct: FxHashSet<FileId> = FxHashSet::default();
124 for &importer in importers {
125 if importer != file {
126 distinct.insert(importer);
127 }
128 }
129 u32::try_from(distinct.len()).unwrap_or(u32::MAX)
130 }
131
132 fn fan_out_count(&self, file: FileId) -> u32 {
135 let mut distinct: FxHashSet<FileId> = FxHashSet::default();
136 for target in self.edges_for(file) {
137 if target != file {
138 distinct.insert(target);
139 }
140 }
141 u32::try_from(distinct.len()).unwrap_or(u32::MAX)
142 }
143
144 fn collect_reference_signal_targets(&self) -> (FxHashSet<FileId>, FxHashSet<FileId>) {
148 let mut dynamic: FxHashSet<FileId> = FxHashSet::default();
149 let mut re_export: FxHashSet<FileId> = FxHashSet::default();
150 for node in &self.modules {
151 for export in &node.exports {
152 for reference in &export.references {
153 match reference.kind {
154 ReferenceKind::DynamicImport => {
155 dynamic.insert(node.file_id);
156 }
157 ReferenceKind::ReExport => {
158 re_export.insert(node.file_id);
159 }
160 _ => {}
161 }
162 }
163 }
164 }
165 (dynamic, re_export)
166 }
167
168 fn has_dynamic_outgoing_edge(&self, file: FileId) -> bool {
173 self.modules.iter().any(|node| {
176 node.exports.iter().any(|export| {
177 export.references.iter().any(|reference| {
178 reference.kind == ReferenceKind::DynamicImport && reference.from_file == file
179 })
180 })
181 })
182 }
183
184 fn is_re_export_participant(
189 &self,
190 file: FileId,
191 re_export_ref_targets: &FxHashSet<FileId>,
192 ) -> bool {
193 if re_export_ref_targets.contains(&file) {
194 return true;
195 }
196 if let Some(node) = self.modules.get(file.0 as usize)
198 && !node.re_exports.is_empty()
199 {
200 return true;
201 }
202 self.modules
204 .iter()
205 .any(|node| node.re_exports.iter().any(|edge| edge.source_file == file))
206 }
207
208 #[must_use]
212 pub fn focus_facts_with_paths(
213 &self,
214 facts: &[FocusFileFacts],
215 root: &Path,
216 ) -> Vec<FocusFileFactsPaths> {
217 let mut resolved: Vec<FocusFileFactsPaths> = facts
218 .iter()
219 .filter_map(|f| {
220 let path = self.modules.get(f.file.0 as usize)?;
221 Some(FocusFileFactsPaths {
222 file: relativize(&path.path, root),
223 fan_in: f.fan_in,
224 fan_out: f.fan_out,
225 dynamic_dispatch: f.dynamic_dispatch,
226 re_export_indirection: f.re_export_indirection,
227 })
228 })
229 .collect();
230 resolved.sort_by(|a, b| a.file.cmp(&b.file));
231 resolved
232 }
233}
234
235fn relativize(path: &Path, root: &Path) -> String {
238 let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
239 rel.to_string_lossy().replace('\\', "/")
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
246 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
247 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
248 use std::path::PathBuf;
249
250 fn file(id: u32, path: &str) -> DiscoveredFile {
251 DiscoveredFile {
252 id: FileId(id),
253 path: PathBuf::from(path),
254 size_bytes: 10,
255 }
256 }
257
258 fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
259 ResolvedImport {
260 info: ImportInfo {
261 source: source.to_string(),
262 imported_name: ImportedName::Named(name.to_string()),
263 local_name: name.to_string(),
264 is_type_only: false,
265 from_style: false,
266 span: oxc_span::Span::new(0, 10),
267 source_span: oxc_span::Span::default(),
268 },
269 target: ResolveResult::InternalModule(target),
270 }
271 }
272
273 fn named_export(name: &str) -> ExportInfo {
274 ExportInfo {
275 name: ExportName::Named(name.to_string()),
276 local_name: Some(name.to_string()),
277 is_type_only: false,
278 visibility: VisibilityTag::None,
279 expected_unused_reason: None,
280 span: oxc_span::Span::new(0, 20),
281 members: vec![],
282 is_side_effect_used: false,
283 super_class: None,
284 }
285 }
286
287 fn build_chain_graph() -> ModuleGraph {
289 let files = vec![
290 file(0, "/p/src/core.ts"),
291 file(1, "/p/src/mid.ts"),
292 file(2, "/p/src/app.ts"),
293 ];
294 let entry_points = vec![EntryPoint {
295 path: PathBuf::from("/p/src/app.ts"),
296 source: EntryPointSource::PackageJsonMain,
297 }];
298 let resolved = vec![
299 ResolvedModule {
300 file_id: FileId(0),
301 path: PathBuf::from("/p/src/core.ts"),
302 exports: vec![named_export("compute")],
303 ..Default::default()
304 },
305 ResolvedModule {
306 file_id: FileId(1),
307 path: PathBuf::from("/p/src/mid.ts"),
308 resolved_imports: vec![named_import("./core", "compute", FileId(0))],
309 exports: vec![named_export("midFn")],
310 ..Default::default()
311 },
312 ResolvedModule {
313 file_id: FileId(2),
314 path: PathBuf::from("/p/src/app.ts"),
315 resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
316 ..Default::default()
317 },
318 ];
319 ModuleGraph::build(&resolved, &entry_points, &files)
320 }
321
322 #[test]
323 fn fan_in_counts_importers() {
324 let graph = build_chain_graph();
325 let facts = graph.focus_file_facts(&[FileId(0)]);
327 assert_eq!(facts.len(), 1);
328 assert_eq!(facts[0].fan_in, 1);
329 assert_eq!(facts[0].fan_out, 0);
330 }
331
332 #[test]
333 fn fan_out_counts_forward_deps() {
334 let graph = build_chain_graph();
335 let facts = graph.focus_file_facts(&[FileId(2)]);
337 assert_eq!(facts.len(), 1);
338 assert_eq!(facts[0].fan_out, 1);
339 assert_eq!(facts[0].fan_in, 0);
340 }
341
342 #[test]
343 fn focus_facts_are_byte_identical_across_runs() {
344 let graph = build_chain_graph();
345 let changed = [FileId(0), FileId(1), FileId(2)];
346 let first = graph.focus_file_facts(&changed);
347 let second = graph.focus_file_facts(&changed);
348 assert_eq!(first, second);
349 let p1 = graph.focus_facts_with_paths(&first, Path::new("/p"));
350 let p2 = graph.focus_facts_with_paths(&second, Path::new("/p"));
351 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
352 }
353
354 #[test]
355 fn re_export_barrel_flags_indirection() {
356 use crate::resolve::ResolvedReExport;
357 use fallow_types::extract::ReExportInfo;
358
359 let files = vec![
360 file(0, "/p/src/impl.ts"),
361 file(1, "/p/src/barrel.ts"),
362 file(2, "/p/src/consumer.ts"),
363 ];
364 let entry_points = vec![EntryPoint {
365 path: PathBuf::from("/p/src/consumer.ts"),
366 source: EntryPointSource::PackageJsonMain,
367 }];
368 let resolved = vec![
369 ResolvedModule {
370 file_id: FileId(0),
371 path: PathBuf::from("/p/src/impl.ts"),
372 exports: vec![named_export("widget")],
373 ..Default::default()
374 },
375 ResolvedModule {
376 file_id: FileId(1),
377 path: PathBuf::from("/p/src/barrel.ts"),
378 re_exports: vec![ResolvedReExport {
379 info: ReExportInfo {
380 source: "./impl".to_string(),
381 imported_name: "widget".to_string(),
382 exported_name: "widget".to_string(),
383 is_type_only: false,
384 span: oxc_span::Span::new(0, 10),
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}