use std::path::{Path, PathBuf};
use fallow_types::discover::FileId;
use rustc_hash::FxHashSet;
use super::{ModuleGraph, ReferenceKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FocusFileFacts {
pub file: FileId,
pub fan_in: u32,
pub fan_out: u32,
pub dynamic_dispatch: bool,
pub re_export_indirection: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FocusFileFactsPaths {
pub file: String,
pub fan_in: u32,
pub fan_out: u32,
pub dynamic_dispatch: bool,
pub re_export_indirection: bool,
}
impl ModuleGraph {
#[must_use]
pub fn focus_file_facts(&self, changed: &[FileId]) -> Vec<FocusFileFacts> {
let mut seen = FxHashSet::default();
let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
for &id in changed {
if (id.0 as usize) < self.modules.len() && seen.insert(id) {
changed_ids.push(id);
}
}
changed_ids.sort_unstable_by_key(|f| f.0);
let (dynamic_targets, re_export_ref_targets) = self.collect_reference_signal_targets();
changed_ids
.iter()
.map(|&id| {
let fan_in = self.fan_in_count(id);
let fan_out = self.fan_out_count(id);
let dynamic_dispatch =
dynamic_targets.contains(&id) || self.has_dynamic_outgoing_edge(id);
let re_export_indirection = re_export_ref_targets.contains(&id)
|| self.is_re_export_participant(id, &re_export_ref_targets);
FocusFileFacts {
file: id,
fan_in,
fan_out,
dynamic_dispatch,
re_export_indirection,
}
})
.collect()
}
fn fan_in_count(&self, file: FileId) -> u32 {
let Some(importers) = self.reverse_deps.get(file.0 as usize) else {
return 0;
};
let mut distinct: FxHashSet<FileId> = FxHashSet::default();
for &importer in importers {
if importer != file {
distinct.insert(importer);
}
}
u32::try_from(distinct.len()).unwrap_or(u32::MAX)
}
fn fan_out_count(&self, file: FileId) -> u32 {
let mut distinct: FxHashSet<FileId> = FxHashSet::default();
for target in self.edges_for(file) {
if target != file {
distinct.insert(target);
}
}
u32::try_from(distinct.len()).unwrap_or(u32::MAX)
}
fn collect_reference_signal_targets(&self) -> (FxHashSet<FileId>, FxHashSet<FileId>) {
let mut dynamic: FxHashSet<FileId> = FxHashSet::default();
let mut re_export: FxHashSet<FileId> = FxHashSet::default();
for node in &self.modules {
for export in &node.exports {
for reference in &export.references {
match reference.kind {
ReferenceKind::DynamicImport => {
dynamic.insert(node.file_id);
}
ReferenceKind::ReExport => {
re_export.insert(node.file_id);
}
_ => {}
}
}
}
}
(dynamic, re_export)
}
fn has_dynamic_outgoing_edge(&self, file: FileId) -> bool {
self.modules.iter().any(|node| {
node.exports.iter().any(|export| {
export.references.iter().any(|reference| {
reference.kind == ReferenceKind::DynamicImport && reference.from_file == file
})
})
})
}
fn is_re_export_participant(
&self,
file: FileId,
re_export_ref_targets: &FxHashSet<FileId>,
) -> bool {
if re_export_ref_targets.contains(&file) {
return true;
}
if let Some(node) = self.modules.get(file.0 as usize)
&& !node.re_exports.is_empty()
{
return true;
}
self.modules
.iter()
.any(|node| node.re_exports.iter().any(|edge| edge.source_file == file))
}
#[must_use]
pub fn focus_facts_with_paths(
&self,
facts: &[FocusFileFacts],
root: &Path,
) -> Vec<FocusFileFactsPaths> {
let mut resolved: Vec<FocusFileFactsPaths> = facts
.iter()
.filter_map(|f| {
let path = self.modules.get(f.file.0 as usize)?;
Some(FocusFileFactsPaths {
file: relativize(&path.path, root),
fan_in: f.fan_in,
fan_out: f.fan_out,
dynamic_dispatch: f.dynamic_dispatch,
re_export_indirection: f.re_export_indirection,
})
})
.collect();
resolved.sort_by(|a, b| a.file.cmp(&b.file));
resolved
}
}
fn relativize(path: &Path, root: &Path) -> String {
let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
rel.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
use std::path::PathBuf;
fn file(id: u32, path: &str) -> DiscoveredFile {
DiscoveredFile {
id: FileId(id),
path: PathBuf::from(path),
size_bytes: 10,
}
}
fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
ResolvedImport {
info: ImportInfo {
source: source.to_string(),
imported_name: ImportedName::Named(name.to_string()),
local_name: name.to_string(),
is_type_only: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(target),
}
}
fn named_export(name: &str) -> ExportInfo {
ExportInfo {
name: ExportName::Named(name.to_string()),
local_name: Some(name.to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: oxc_span::Span::new(0, 20),
members: vec![],
is_side_effect_used: false,
super_class: None,
}
}
fn build_chain_graph() -> ModuleGraph {
let files = vec![
file(0, "/p/src/core.ts"),
file(1, "/p/src/mid.ts"),
file(2, "/p/src/app.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/app.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/core.ts"),
exports: vec![named_export("compute")],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/mid.ts"),
resolved_imports: vec![named_import("./core", "compute", FileId(0))],
exports: vec![named_export("midFn")],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/app.ts"),
resolved_imports: vec![named_import("./mid", "midFn", FileId(1))],
..Default::default()
},
];
ModuleGraph::build(&resolved, &entry_points, &files)
}
#[test]
fn fan_in_counts_importers() {
let graph = build_chain_graph();
let facts = graph.focus_file_facts(&[FileId(0)]);
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].fan_in, 1);
assert_eq!(facts[0].fan_out, 0);
}
#[test]
fn fan_out_counts_forward_deps() {
let graph = build_chain_graph();
let facts = graph.focus_file_facts(&[FileId(2)]);
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].fan_out, 1);
assert_eq!(facts[0].fan_in, 0);
}
#[test]
fn focus_facts_are_byte_identical_across_runs() {
let graph = build_chain_graph();
let changed = [FileId(0), FileId(1), FileId(2)];
let first = graph.focus_file_facts(&changed);
let second = graph.focus_file_facts(&changed);
assert_eq!(first, second);
let p1 = graph.focus_facts_with_paths(&first, Path::new("/p"));
let p2 = graph.focus_facts_with_paths(&second, Path::new("/p"));
assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
}
#[test]
fn re_export_barrel_flags_indirection() {
use crate::resolve::ResolvedReExport;
use fallow_types::extract::ReExportInfo;
let files = vec![
file(0, "/p/src/impl.ts"),
file(1, "/p/src/barrel.ts"),
file(2, "/p/src/consumer.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/consumer.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/impl.ts"),
exports: vec![named_export("widget")],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/barrel.ts"),
re_exports: vec![ResolvedReExport {
info: ReExportInfo {
source: "./impl".to_string(),
imported_name: "widget".to_string(),
exported_name: "widget".to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
},
target: ResolveResult::InternalModule(FileId(0)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/consumer.ts"),
resolved_imports: vec![named_import("./barrel", "widget", FileId(1))],
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let barrel = graph.focus_file_facts(&[FileId(1)]);
assert!(barrel[0].re_export_indirection, "barrel flags indirection");
let impl_facts = graph.focus_file_facts(&[FileId(0)]);
assert!(
impl_facts[0].re_export_indirection,
"re-export source flags indirection"
);
}
#[test]
fn empty_changed_set_yields_no_facts() {
let graph = build_chain_graph();
assert!(graph.focus_file_facts(&[]).is_empty());
}
#[test]
fn out_of_range_ids_are_dropped() {
let graph = build_chain_graph();
let facts = graph.focus_file_facts(&[FileId(999)]);
assert!(facts.is_empty());
}
}