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
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 from_style: false,
263 span: oxc_span::Span::new(0, 10),
264 source_span: oxc_span::Span::default(),
265 },
266 target: ResolveResult::InternalModule(target),
267 }
268 }
269
270 fn named_export(name: &str) -> ExportInfo {
271 ExportInfo {
272 name: ExportName::Named(name.to_string()),
273 local_name: Some(name.to_string()),
274 is_type_only: false,
275 visibility: VisibilityTag::None,
276 expected_unused_reason: None,
277 span: oxc_span::Span::new(0, 20),
278 members: vec![],
279 is_side_effect_used: false,
280 super_class: None,
281 }
282 }
283
284 fn build_chain_graph() -> ModuleGraph {
286 let files = vec![
287 file(0, "/p/src/core.ts"),
288 file(1, "/p/src/mid.ts"),
289 file(2, "/p/src/app.ts"),
290 ];
291 let entry_points = vec![EntryPoint {
292 path: PathBuf::from("/p/src/app.ts"),
293 source: EntryPointSource::PackageJsonMain,
294 }];
295 let resolved = vec![
296 ResolvedModule {
297 file_id: FileId(0),
298 path: PathBuf::from("/p/src/core.ts"),
299 exports: vec![named_export("compute")],
300 ..Default::default()
301 },
302 ResolvedModule {
303 file_id: FileId(1),
304 path: PathBuf::from("/p/src/mid.ts"),
305 resolved_imports: vec![named_import("./core", "compute", FileId(0))],
306 exports: vec![named_export("midFn")],
307 ..Default::default()
308 },
309 ResolvedModule {
310 file_id: FileId(2),
311 path: PathBuf::from("/p/src/app.ts"),
312 resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
313 ..Default::default()
314 },
315 ];
316 ModuleGraph::build(&resolved, &entry_points, &files)
317 }
318
319 #[test]
320 fn fan_in_counts_importers() {
321 let graph = build_chain_graph();
322 let facts = graph.focus_file_facts(&[FileId(0)]);
324 assert_eq!(facts.len(), 1);
325 assert_eq!(facts[0].fan_in, 1);
326 assert_eq!(facts[0].fan_out, 0);
327 }
328
329 #[test]
330 fn fan_out_counts_forward_deps() {
331 let graph = build_chain_graph();
332 let facts = graph.focus_file_facts(&[FileId(2)]);
334 assert_eq!(facts.len(), 1);
335 assert_eq!(facts[0].fan_out, 1);
336 assert_eq!(facts[0].fan_in, 0);
337 }
338
339 #[test]
340 fn focus_facts_are_byte_identical_across_runs() {
341 let graph = build_chain_graph();
342 let changed = [FileId(0), FileId(1), FileId(2)];
343 let first = graph.focus_file_facts(&changed);
344 let second = graph.focus_file_facts(&changed);
345 assert_eq!(first, second);
346 let p1 = graph.focus_facts_with_paths(&first, Path::new("/p"));
347 let p2 = graph.focus_facts_with_paths(&second, Path::new("/p"));
348 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
349 }
350
351 #[test]
352 fn re_export_barrel_flags_indirection() {
353 use crate::resolve::ResolvedReExport;
354 use fallow_types::extract::ReExportInfo;
355
356 let files = vec![
357 file(0, "/p/src/impl.ts"),
358 file(1, "/p/src/barrel.ts"),
359 file(2, "/p/src/consumer.ts"),
360 ];
361 let entry_points = vec![EntryPoint {
362 path: PathBuf::from("/p/src/consumer.ts"),
363 source: EntryPointSource::PackageJsonMain,
364 }];
365 let resolved = vec![
366 ResolvedModule {
367 file_id: FileId(0),
368 path: PathBuf::from("/p/src/impl.ts"),
369 exports: vec![named_export("widget")],
370 ..Default::default()
371 },
372 ResolvedModule {
373 file_id: FileId(1),
374 path: PathBuf::from("/p/src/barrel.ts"),
375 re_exports: vec![ResolvedReExport {
376 info: ReExportInfo {
377 source: "./impl".to_string(),
378 imported_name: "widget".to_string(),
379 exported_name: "widget".to_string(),
380 is_type_only: false,
381 span: oxc_span::Span::new(0, 10),
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}