use std::path::{Path, PathBuf};
use fallow_types::discover::FileId;
use rustc_hash::{FxHashMap, FxHashSet};
use super::ModuleGraph;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewUnit {
pub module_dir: String,
pub files: Vec<FileId>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PartitionOrder {
pub units: Vec<ReviewUnit>,
pub order: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PartitionOrderPaths {
pub units: Vec<ReviewUnitPaths>,
pub order: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewUnitPaths {
pub module_dir: String,
pub files: Vec<String>,
}
impl ModuleGraph {
#[must_use]
pub fn partition_order(&self, changed: &[FileId]) -> PartitionOrder {
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 units = self.build_units(&changed_ids);
let order = self.order_units(&units, &changed_ids);
PartitionOrder { units, order }
}
fn build_units(&self, changed_ids: &[FileId]) -> Vec<ReviewUnit> {
let mut by_dir: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
for &id in changed_ids {
let Some(module) = self.modules.get(id.0 as usize) else {
continue;
};
let dir = module_dir_key(&module.path);
by_dir.entry(dir).or_default().push(id);
}
let mut units: Vec<ReviewUnit> = by_dir
.into_iter()
.map(|(module_dir, mut files)| {
files.sort_unstable_by_key(|f| f.0);
ReviewUnit { module_dir, files }
})
.collect();
units.sort_by(|a, b| a.module_dir.cmp(&b.module_dir));
units
}
fn order_units(&self, units: &[ReviewUnit], changed_ids: &[FileId]) -> Vec<String> {
if units.is_empty() {
return Vec::new();
}
let unit_of: FxHashMap<FileId, usize> = units
.iter()
.enumerate()
.flat_map(|(i, unit)| unit.files.iter().map(move |&f| (f, i)))
.collect();
let unit_count = units.len();
let mut deps: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); unit_count];
for &id in changed_ids {
let Some(&consumer_unit) = unit_of.get(&id) else {
continue;
};
for dep_target in self.edges_for(id) {
let Some(&dep_unit) = unit_of.get(&dep_target) else {
continue;
};
if dep_unit != consumer_unit {
deps[consumer_unit].insert(dep_unit);
}
}
}
kahn_min_pick(units, &deps)
}
#[must_use]
pub fn partition_order_with_paths(
&self,
partition: &PartitionOrder,
root: &Path,
) -> PartitionOrderPaths {
let resolve = |id: FileId| -> Option<String> {
self.modules
.get(id.0 as usize)
.map(|m| relativize(&m.path, root))
};
let units: Vec<ReviewUnitPaths> = partition
.units
.iter()
.filter_map(|unit| {
let mut files: Vec<String> =
unit.files.iter().filter_map(|&id| resolve(id)).collect();
if files.is_empty() {
return None;
}
files.sort();
Some(ReviewUnitPaths {
module_dir: relativize_dir(&unit.module_dir, root),
files,
})
})
.collect();
let order: Vec<String> = partition
.order
.iter()
.map(|dir| relativize_dir(dir, root))
.collect();
PartitionOrderPaths { units, order }
}
}
fn kahn_min_pick(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<String> {
let unit_count = units.len();
let mut remaining: FxHashSet<usize> = (0..unit_count).collect();
let mut emitted: FxHashSet<usize> = FxHashSet::default();
let mut order: Vec<String> = Vec::with_capacity(unit_count);
while !remaining.is_empty() {
let mut ready: Option<usize> = None;
for &idx in &remaining {
let all_deps_emitted = deps[idx].iter().all(|d| emitted.contains(d));
if !all_deps_emitted {
continue;
}
ready = Some(match ready {
Some(cur) if units[cur].module_dir <= units[idx].module_dir => cur,
_ => idx,
});
}
match ready {
Some(idx) => {
order.push(units[idx].module_dir.clone());
emitted.insert(idx);
remaining.remove(&idx);
}
None => {
let mut rest: Vec<usize> = remaining.iter().copied().collect();
rest.sort_by(|&a, &b| units[a].module_dir.cmp(&units[b].module_dir));
for idx in rest {
order.push(units[idx].module_dir.clone());
}
break;
}
}
}
order
}
fn module_dir_key(path: &Path) -> String {
path.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default()
}
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('\\', "/")
}
fn relativize_dir(dir: &str, root: &Path) -> String {
if dir.is_empty() {
return String::new();
}
relativize(Path::new(dir), root)
}
#[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_three_dir_graph() -> ModuleGraph {
let files = vec![
file(0, "/p/src/app/x.ts"),
file(1, "/p/src/core/a.ts"),
file(2, "/p/src/core/b.ts"),
file(3, "/p/src/mid/m.ts"),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/app/x.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/app/x.ts"),
resolved_imports: vec![named_import("../mid/m", "midFn", FileId(3))],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/core/a.ts"),
exports: vec![named_export("alpha")],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/p/src/core/b.ts"),
exports: vec![named_export("beta")],
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: PathBuf::from("/p/src/mid/m.ts"),
resolved_imports: vec![named_import("../core/a", "alpha", FileId(1))],
exports: vec![named_export("midFn")],
..Default::default()
},
];
ModuleGraph::build(&resolved, &entry_points, &files)
}
#[test]
fn partition_groups_changed_files_by_module_directory() {
let graph = build_three_dir_graph();
let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
let dirs: Vec<&str> = paths.units.iter().map(|u| u.module_dir.as_str()).collect();
assert_eq!(dirs, vec!["src/app", "src/core", "src/mid"]);
let core = paths
.units
.iter()
.find(|u| u.module_dir == "src/core")
.expect("core unit");
assert_eq!(core.files, vec!["src/core/a.ts", "src/core/b.ts"]);
}
#[test]
fn order_places_definitions_before_consumers() {
let graph = build_three_dir_graph();
let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
assert_eq!(
partition.order,
vec![
"/p/src/core".to_string(),
"/p/src/mid".to_string(),
"/p/src/app".to_string(),
]
);
}
#[test]
fn independent_units_order_by_path_sort() {
let files = vec![file(0, "/p/src/billing/b.ts"), file(1, "/p/src/auth/a.ts")];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/auth/a.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/p/src/billing/b.ts"),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/p/src/auth/a.ts"),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let partition = graph.partition_order(&[FileId(0), FileId(1)]);
assert_eq!(
partition.order,
vec!["/p/src/auth".to_string(), "/p/src/billing".to_string()]
);
}
#[test]
fn partition_order_is_byte_identical_across_runs() {
let graph = build_three_dir_graph();
let changed = [FileId(0), FileId(1), FileId(2), FileId(3)];
let first = graph.partition_order(&changed);
let second = graph.partition_order(&changed);
assert_eq!(first, second);
let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
}
#[test]
fn changed_set_order_does_not_affect_result() {
let graph = build_three_dir_graph();
let a = graph.partition_order(&[FileId(3), FileId(0), FileId(2), FileId(1)]);
let b = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
assert_eq!(a, b);
}
#[test]
fn root_file_clusters_under_root_group() {
let files = vec![file(0, "index.ts")];
let entry_points = vec![EntryPoint {
path: PathBuf::from("index.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved = vec![ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("index.ts"),
..Default::default()
}];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let partition = graph.partition_order(&[FileId(0)]);
assert_eq!(partition.units.len(), 1);
assert_eq!(partition.units[0].module_dir, "");
}
#[test]
fn empty_changed_set_yields_empty_partition() {
let graph = build_three_dir_graph();
let partition = graph.partition_order(&[]);
assert!(partition.units.is_empty());
assert!(partition.order.is_empty());
}
#[test]
fn scale_300_file_multi_module_graph_is_stable() {
const DIRS: u32 = 30;
const PER_DIR: u32 = 10;
let mut files = Vec::new();
let mut resolved = Vec::new();
for d in 0..DIRS {
for f in 0..PER_DIR {
let id = d * PER_DIR + f;
let path = format!("/p/src/mod{d:02}/file{f:02}.ts");
files.push(file(id, &path));
}
}
for d in 0..DIRS {
for f in 0..PER_DIR {
let id = d * PER_DIR + f;
let mut module = ResolvedModule {
file_id: FileId(id),
path: PathBuf::from(format!("/p/src/mod{d:02}/file{f:02}.ts")),
exports: vec![named_export(&format!("e{id}"))],
..Default::default()
};
if f == 0 && d > 0 {
let dep = (d - 1) * PER_DIR;
module.resolved_imports =
vec![named_import("../prev", &format!("e{dep}"), FileId(dep))];
}
resolved.push(module);
}
}
let entry_points = vec![EntryPoint {
path: PathBuf::from("/p/src/mod00/file00.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let graph = ModuleGraph::build(&resolved, &entry_points, &files);
let changed: Vec<FileId> = (0..DIRS * PER_DIR).map(FileId).collect();
let first = graph.partition_order(&changed);
let second = graph.partition_order(&changed);
assert_eq!(first, second, "300-file partition must be stable");
assert_eq!(first.units.len(), DIRS as usize, "one unit per directory");
let expected: Vec<String> = (0..DIRS).map(|d| format!("/p/src/mod{d:02}")).collect();
assert_eq!(
first.order, expected,
"definitions precede consumers at scale"
);
let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
}
}