1use std::path::{Path, PathBuf};
28
29use fallow_types::discover::FileId;
30use rustc_hash::{FxHashMap, FxHashSet};
31
32use super::ModuleGraph;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ReviewUnit {
40 module_dir: String,
44 files: Vec<FileId>,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct PartitionOrder {
53 units: Vec<ReviewUnit>,
55 order: Vec<String>,
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct PartitionOrderPaths {
65 pub units: Vec<ReviewUnitPaths>,
67 pub order: Vec<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ReviewUnitPaths {
74 pub module_dir: String,
76 pub files: Vec<String>,
78}
79
80impl ModuleGraph {
81 #[must_use]
89 pub fn partition_order(&self, changed: &[FileId]) -> PartitionOrder {
90 let mut seen = FxHashSet::default();
92 let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
93 for &id in changed {
94 if (id.0 as usize) < self.modules.len() && seen.insert(id) {
95 changed_ids.push(id);
96 }
97 }
98 changed_ids.sort_unstable_by_key(|f| f.0);
99
100 let units = self.build_units(&changed_ids);
101 let order = self.order_units(&units, &changed_ids);
102 PartitionOrder { units, order }
103 }
104
105 fn build_units(&self, changed_ids: &[FileId]) -> Vec<ReviewUnit> {
108 let mut by_dir: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
111 for &id in changed_ids {
112 let Some(module) = self.modules.get(id.0 as usize) else {
113 continue;
114 };
115 let dir = module_dir_key(&module.path);
116 by_dir.entry(dir).or_default().push(id);
117 }
118
119 let mut units: Vec<ReviewUnit> = by_dir
120 .into_iter()
121 .map(|(module_dir, mut files)| {
122 files.sort_unstable_by_key(|f| f.0);
123 ReviewUnit { module_dir, files }
124 })
125 .collect();
126 units.sort_by(|a, b| a.module_dir.cmp(&b.module_dir));
127 units
128 }
129
130 fn order_units(&self, units: &[ReviewUnit], changed_ids: &[FileId]) -> Vec<String> {
134 if units.is_empty() {
135 return Vec::new();
136 }
137
138 let unit_of: FxHashMap<FileId, usize> = units
140 .iter()
141 .enumerate()
142 .flat_map(|(i, unit)| unit.files.iter().map(move |&f| (f, i)))
143 .collect();
144
145 let unit_count = units.len();
146 let mut deps: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); unit_count];
151 for &id in changed_ids {
152 let Some(&consumer_unit) = unit_of.get(&id) else {
153 continue;
154 };
155 for dep_target in self.edges_for(id) {
156 let Some(&dep_unit) = unit_of.get(&dep_target) else {
157 continue;
158 };
159 if dep_unit != consumer_unit {
160 deps[consumer_unit].insert(dep_unit);
161 }
162 }
163 }
164
165 kahn_min_pick(units, &deps)
166 }
167
168 #[must_use]
174 pub fn partition_order_with_paths(
175 &self,
176 partition: &PartitionOrder,
177 root: &Path,
178 ) -> PartitionOrderPaths {
179 let resolve = |id: FileId| -> Option<String> {
180 self.modules
181 .get(id.0 as usize)
182 .map(|m| relativize(&m.path, root))
183 };
184
185 let units: Vec<ReviewUnitPaths> = partition
186 .units
187 .iter()
188 .filter_map(|unit| {
189 let mut files: Vec<String> =
190 unit.files.iter().filter_map(|&id| resolve(id)).collect();
191 if files.is_empty() {
192 return None;
193 }
194 files.sort();
195 Some(ReviewUnitPaths {
196 module_dir: relativize_dir(&unit.module_dir, root),
197 files,
198 })
199 })
200 .collect();
201
202 let order: Vec<String> = partition
203 .order
204 .iter()
205 .map(|dir| relativize_dir(dir, root))
206 .collect();
207
208 PartitionOrderPaths { units, order }
209 }
210}
211
212fn kahn_min_pick(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<String> {
218 let unit_count = units.len();
219 let mut remaining: FxHashSet<usize> = (0..unit_count).collect();
220 let mut emitted: FxHashSet<usize> = FxHashSet::default();
221 let mut order: Vec<String> = Vec::with_capacity(unit_count);
222
223 while !remaining.is_empty() {
224 let mut ready: Option<usize> = None;
228 for &idx in &remaining {
229 let all_deps_emitted = deps[idx].iter().all(|d| emitted.contains(d));
230 if !all_deps_emitted {
231 continue;
232 }
233 ready = Some(match ready {
234 Some(cur) if units[cur].module_dir <= units[idx].module_dir => cur,
235 _ => idx,
236 });
237 }
238
239 match ready {
240 Some(idx) => {
241 order.push(units[idx].module_dir.clone());
242 emitted.insert(idx);
243 remaining.remove(&idx);
244 }
245 None => {
246 let mut rest: Vec<usize> = remaining.iter().copied().collect();
249 rest.sort_by(|&a, &b| units[a].module_dir.cmp(&units[b].module_dir));
250 for idx in rest {
251 order.push(units[idx].module_dir.clone());
252 }
253 break;
254 }
255 }
256 }
257
258 order
259}
260
261fn module_dir_key(path: &Path) -> String {
264 path.parent()
265 .map(|p| p.to_string_lossy().replace('\\', "/"))
266 .unwrap_or_default()
267}
268
269fn relativize(path: &Path, root: &Path) -> String {
272 let rel: PathBuf = path.strip_prefix(root).unwrap_or(path).to_path_buf();
273 rel.to_string_lossy().replace('\\', "/")
274}
275
276fn relativize_dir(dir: &str, root: &Path) -> String {
280 if dir.is_empty() {
281 return String::new();
282 }
283 relativize(Path::new(dir), root)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
290 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
291 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
292 use std::path::PathBuf;
293
294 fn file(id: u32, path: &str) -> DiscoveredFile {
295 DiscoveredFile {
296 id: FileId(id),
297 path: PathBuf::from(path),
298 size_bytes: 10,
299 }
300 }
301
302 fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
303 ResolvedImport {
304 info: ImportInfo {
305 source: source.to_string(),
306 imported_name: ImportedName::Named(name.to_string()),
307 local_name: name.to_string(),
308 is_type_only: false,
309 is_type_only_star: false,
310 from_style: false,
311 span: oxc_span::Span::new(0, 10),
312 source_span: oxc_span::Span::default(),
313 },
314 target: ResolveResult::InternalModule(target),
315 }
316 }
317
318 fn named_export(name: &str) -> ExportInfo {
319 ExportInfo {
320 name: ExportName::Named(name.to_string()),
321 local_name: Some(name.to_string()),
322 is_type_only: false,
323 visibility: VisibilityTag::None,
324 expected_unused_reason: None,
325 span: oxc_span::Span::new(0, 20),
326 members: vec![],
327 is_side_effect_used: false,
328 super_class: None,
329 }
330 }
331
332 fn build_three_dir_graph() -> ModuleGraph {
335 let files = vec![
336 file(0, "/p/src/app/x.ts"),
337 file(1, "/p/src/core/a.ts"),
338 file(2, "/p/src/core/b.ts"),
339 file(3, "/p/src/mid/m.ts"),
340 ];
341 let entry_points = vec![EntryPoint {
342 path: PathBuf::from("/p/src/app/x.ts"),
343 source: EntryPointSource::PackageJsonMain,
344 }];
345 let resolved = vec![
346 ResolvedModule {
347 file_id: FileId(0),
348 path: PathBuf::from("/p/src/app/x.ts"),
349 resolved_imports: vec![named_import("../mid/m", "midFn", FileId(3))],
350 ..Default::default()
351 },
352 ResolvedModule {
353 file_id: FileId(1),
354 path: PathBuf::from("/p/src/core/a.ts"),
355 exports: vec![named_export("alpha")].into(),
356 ..Default::default()
357 },
358 ResolvedModule {
359 file_id: FileId(2),
360 path: PathBuf::from("/p/src/core/b.ts"),
361 exports: vec![named_export("beta")].into(),
362 ..Default::default()
363 },
364 ResolvedModule {
365 file_id: FileId(3),
366 path: PathBuf::from("/p/src/mid/m.ts"),
367 resolved_imports: vec![named_import("../core/a", "alpha", FileId(1))],
368 exports: vec![named_export("midFn")].into(),
369 ..Default::default()
370 },
371 ];
372 ModuleGraph::build(&resolved, &entry_points, &files)
373 }
374
375 #[test]
376 fn partition_groups_changed_files_by_module_directory() {
377 let graph = build_three_dir_graph();
378 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
380 let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
381 let dirs: Vec<&str> = paths.units.iter().map(|u| u.module_dir.as_str()).collect();
383 assert_eq!(dirs, vec!["src/app", "src/core", "src/mid"]);
384 let core = paths
386 .units
387 .iter()
388 .find(|u| u.module_dir == "src/core")
389 .expect("core unit");
390 assert_eq!(core.files, vec!["src/core/a.ts", "src/core/b.ts"]);
391 }
392
393 #[test]
394 fn order_places_definitions_before_consumers() {
395 let graph = build_three_dir_graph();
396 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
398 assert_eq!(
399 partition.order,
400 vec![
401 "/p/src/core".to_string(),
402 "/p/src/mid".to_string(),
403 "/p/src/app".to_string(),
404 ]
405 );
406 }
407
408 #[test]
409 fn independent_units_order_by_path_sort() {
410 let files = vec![file(0, "/p/src/billing/b.ts"), file(1, "/p/src/auth/a.ts")];
412 let entry_points = vec![EntryPoint {
413 path: PathBuf::from("/p/src/auth/a.ts"),
414 source: EntryPointSource::PackageJsonMain,
415 }];
416 let resolved = vec![
417 ResolvedModule {
418 file_id: FileId(0),
419 path: PathBuf::from("/p/src/billing/b.ts"),
420 ..Default::default()
421 },
422 ResolvedModule {
423 file_id: FileId(1),
424 path: PathBuf::from("/p/src/auth/a.ts"),
425 ..Default::default()
426 },
427 ];
428 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
429 let partition = graph.partition_order(&[FileId(0), FileId(1)]);
430 assert_eq!(
431 partition.order,
432 vec!["/p/src/auth".to_string(), "/p/src/billing".to_string()]
433 );
434 }
435
436 #[test]
437 fn partition_order_is_byte_identical_across_runs() {
438 let graph = build_three_dir_graph();
439 let changed = [FileId(0), FileId(1), FileId(2), FileId(3)];
440 let first = graph.partition_order(&changed);
441 let second = graph.partition_order(&changed);
442 assert_eq!(first, second);
444 let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
447 let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
448 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
449 }
450
451 #[test]
452 fn changed_set_order_does_not_affect_result() {
453 let graph = build_three_dir_graph();
456 let a = graph.partition_order(&[FileId(3), FileId(0), FileId(2), FileId(1)]);
457 let b = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
458 assert_eq!(a, b);
459 }
460
461 #[test]
462 fn root_file_clusters_under_root_group() {
463 let files = vec![file(0, "index.ts")];
464 let entry_points = vec![EntryPoint {
465 path: PathBuf::from("index.ts"),
466 source: EntryPointSource::PackageJsonMain,
467 }];
468 let resolved = vec![ResolvedModule {
469 file_id: FileId(0),
470 path: PathBuf::from("index.ts"),
471 ..Default::default()
472 }];
473 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
474 let partition = graph.partition_order(&[FileId(0)]);
475 assert_eq!(partition.units.len(), 1);
476 assert_eq!(partition.units[0].module_dir, "");
477 }
478
479 #[test]
480 fn empty_changed_set_yields_empty_partition() {
481 let graph = build_three_dir_graph();
482 let partition = graph.partition_order(&[]);
483 assert!(partition.units.is_empty());
484 assert!(partition.order.is_empty());
485 }
486
487 #[test]
488 fn scale_300_file_multi_module_graph_is_stable() {
489 const DIRS: u32 = 30;
493 const PER_DIR: u32 = 10;
494 let mut files = Vec::new();
495 let mut resolved = Vec::new();
496 for d in 0..DIRS {
497 for f in 0..PER_DIR {
498 let id = d * PER_DIR + f;
499 let path = format!("/p/src/mod{d:02}/file{f:02}.ts");
500 files.push(file(id, &path));
501 }
502 }
503 for d in 0..DIRS {
504 for f in 0..PER_DIR {
505 let id = d * PER_DIR + f;
506 let mut module = ResolvedModule {
507 file_id: FileId(id),
508 path: PathBuf::from(format!("/p/src/mod{d:02}/file{f:02}.ts")),
509 exports: vec![named_export(&format!("e{id}"))].into(),
510 ..Default::default()
511 };
512 if f == 0 && d > 0 {
515 let dep = (d - 1) * PER_DIR;
516 module.resolved_imports =
517 vec![named_import("../prev", &format!("e{dep}"), FileId(dep))];
518 }
519 resolved.push(module);
520 }
521 }
522 let entry_points = vec![EntryPoint {
523 path: PathBuf::from("/p/src/mod00/file00.ts"),
524 source: EntryPointSource::PackageJsonMain,
525 }];
526 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
527
528 let changed: Vec<FileId> = (0..DIRS * PER_DIR).map(FileId).collect();
529 let first = graph.partition_order(&changed);
530 let second = graph.partition_order(&changed);
531 assert_eq!(first, second, "300-file partition must be stable");
532 assert_eq!(first.units.len(), DIRS as usize, "one unit per directory");
533 let expected: Vec<String> = (0..DIRS).map(|d| format!("/p/src/mod{d:02}")).collect();
535 assert_eq!(
536 first.order, expected,
537 "definitions precede consumers at scale"
538 );
539 let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
541 let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
542 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
543 }
544}