1use std::path::Path;
28
29use super::relativize;
30
31use fallow_types::discover::FileId;
32use rustc_hash::{FxHashMap, FxHashSet};
33
34use super::ModuleGraph;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ReviewUnit {
42 module_dir: String,
46 files: Vec<FileId>,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct PartitionOrder {
55 units: Vec<ReviewUnit>,
57 order: Vec<String>,
61 independent_slices: Vec<Vec<String>>,
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct PartitionOrderPaths {
73 pub units: Vec<ReviewUnitPaths>,
75 pub order: Vec<String>,
77 pub independent_slices: Vec<Vec<String>>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ReviewUnitPaths {
85 pub module_dir: String,
87 pub files: Vec<String>,
89}
90
91impl ModuleGraph {
92 #[must_use]
100 pub fn partition_order(&self, changed: &[FileId]) -> PartitionOrder {
101 let mut seen = FxHashSet::default();
103 let mut changed_ids: Vec<FileId> = Vec::with_capacity(changed.len());
104 for &id in changed {
105 if (id.0 as usize) < self.modules.len() && seen.insert(id) {
106 changed_ids.push(id);
107 }
108 }
109 changed_ids.sort_unstable_by_key(|f| f.0);
110
111 let units = self.build_units(&changed_ids);
112 let deps = self.unit_deps(&units, &changed_ids);
113 let order = if units.is_empty() {
114 Vec::new()
115 } else {
116 kahn_min_pick(&units, &deps)
117 };
118 let independent_slices = independent_slices(&units, &deps);
119 PartitionOrder {
120 units,
121 order,
122 independent_slices,
123 }
124 }
125
126 fn build_units(&self, changed_ids: &[FileId]) -> Vec<ReviewUnit> {
129 let mut by_dir: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
132 for &id in changed_ids {
133 let Some(module) = self.modules.get(id.0 as usize) else {
134 continue;
135 };
136 let dir = module_dir_key(&module.path);
137 by_dir.entry(dir).or_default().push(id);
138 }
139
140 let mut units: Vec<ReviewUnit> = by_dir
141 .into_iter()
142 .map(|(module_dir, mut files)| {
143 files.sort_unstable_by_key(|f| f.0);
144 ReviewUnit { module_dir, files }
145 })
146 .collect();
147 units.sort_by(|a, b| a.module_dir.cmp(&b.module_dir));
148 units
149 }
150
151 fn unit_deps(&self, units: &[ReviewUnit], changed_ids: &[FileId]) -> Vec<FxHashSet<usize>> {
155 let unit_of: FxHashMap<FileId, usize> = units
157 .iter()
158 .enumerate()
159 .flat_map(|(i, unit)| unit.files.iter().map(move |&f| (f, i)))
160 .collect();
161
162 let unit_count = units.len();
163 let mut deps: Vec<FxHashSet<usize>> = vec![FxHashSet::default(); unit_count];
168 for &id in changed_ids {
169 let Some(&consumer_unit) = unit_of.get(&id) else {
170 continue;
171 };
172 for dep_target in self.edges_for(id) {
173 let Some(&dep_unit) = unit_of.get(&dep_target) else {
174 continue;
175 };
176 if dep_unit != consumer_unit {
177 deps[consumer_unit].insert(dep_unit);
178 }
179 }
180 }
181 deps
182 }
183
184 #[must_use]
190 pub fn partition_order_with_paths(
191 &self,
192 partition: &PartitionOrder,
193 root: &Path,
194 ) -> PartitionOrderPaths {
195 let resolve = |id: FileId| -> Option<String> {
196 self.modules
197 .get(id.0 as usize)
198 .map(|m| relativize(&m.path, root))
199 };
200
201 let units: Vec<ReviewUnitPaths> = partition
202 .units
203 .iter()
204 .filter_map(|unit| {
205 let mut files: Vec<String> =
206 unit.files.iter().filter_map(|&id| resolve(id)).collect();
207 if files.is_empty() {
208 return None;
209 }
210 files.sort();
211 Some(ReviewUnitPaths {
212 module_dir: relativize_dir(&unit.module_dir, root),
213 files,
214 })
215 })
216 .collect();
217
218 let order: Vec<String> = partition
219 .order
220 .iter()
221 .map(|dir| relativize_dir(dir, root))
222 .collect();
223 let mut independent_slices: Vec<Vec<String>> = partition
224 .independent_slices
225 .iter()
226 .map(|slice| {
227 let mut dirs: Vec<String> =
228 slice.iter().map(|dir| relativize_dir(dir, root)).collect();
229 dirs.sort();
230 dirs
231 })
232 .collect();
233 independent_slices.sort();
234
235 PartitionOrderPaths {
236 units,
237 order,
238 independent_slices,
239 }
240 }
241}
242
243fn independent_slices(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<Vec<String>> {
249 let unit_count = units.len();
250 let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); unit_count];
251 for (consumer, targets) in deps.iter().enumerate() {
252 for &target in targets {
253 adjacency[consumer].push(target);
254 adjacency[target].push(consumer);
255 }
256 }
257
258 let mut component_of: Vec<Option<usize>> = vec![None; unit_count];
259 let mut slices: Vec<Vec<String>> = Vec::new();
260 for start in 0..unit_count {
261 if component_of[start].is_some() {
262 continue;
263 }
264 let component = slices.len();
265 let mut stack = vec![start];
266 let mut members: Vec<String> = Vec::new();
267 while let Some(idx) = stack.pop() {
268 if component_of[idx].is_some() {
269 continue;
270 }
271 component_of[idx] = Some(component);
272 members.push(units[idx].module_dir.clone());
273 stack.extend(adjacency[idx].iter().copied());
274 }
275 members.sort();
276 slices.push(members);
277 }
278 slices.sort();
279 slices
280}
281
282fn kahn_min_pick(units: &[ReviewUnit], deps: &[FxHashSet<usize>]) -> Vec<String> {
288 let unit_count = units.len();
289 let mut remaining: FxHashSet<usize> = (0..unit_count).collect();
290 let mut emitted: FxHashSet<usize> = FxHashSet::default();
291 let mut order: Vec<String> = Vec::with_capacity(unit_count);
292
293 while !remaining.is_empty() {
294 let mut ready: Option<usize> = None;
298 for &idx in &remaining {
299 let all_deps_emitted = deps[idx].iter().all(|d| emitted.contains(d));
300 if !all_deps_emitted {
301 continue;
302 }
303 ready = Some(match ready {
304 Some(cur) if units[cur].module_dir <= units[idx].module_dir => cur,
305 _ => idx,
306 });
307 }
308
309 match ready {
310 Some(idx) => {
311 order.push(units[idx].module_dir.clone());
312 emitted.insert(idx);
313 remaining.remove(&idx);
314 }
315 None => {
316 let mut rest: Vec<usize> = remaining.iter().copied().collect();
319 rest.sort_by(|&a, &b| units[a].module_dir.cmp(&units[b].module_dir));
320 for idx in rest {
321 order.push(units[idx].module_dir.clone());
322 }
323 break;
324 }
325 }
326 }
327
328 order
329}
330
331fn module_dir_key(path: &Path) -> String {
334 path.parent()
335 .map(|p| p.to_string_lossy().replace('\\', "/"))
336 .unwrap_or_default()
337}
338
339fn relativize_dir(dir: &str, root: &Path) -> String {
343 if dir.is_empty() {
344 return String::new();
345 }
346 relativize(Path::new(dir), root)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
353 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
354 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
355 use std::path::PathBuf;
356
357 fn file(id: u32, path: &str) -> DiscoveredFile {
358 DiscoveredFile {
359 id: FileId(id),
360 path: PathBuf::from(path),
361 size_bytes: 10,
362 }
363 }
364
365 fn named_import(source: &str, name: &str, target: FileId) -> ResolvedImport {
366 ResolvedImport {
367 info: ImportInfo {
368 source: source.to_string(),
369 imported_name: ImportedName::Named(name.to_string()),
370 local_name: name.to_string(),
371 is_type_only: false,
372 is_type_only_star: false,
373 from_style: false,
374 span: oxc_span::Span::new(0, 10),
375 source_span: oxc_span::Span::default(),
376 },
377 target: ResolveResult::InternalModule(target),
378 }
379 }
380
381 fn named_export(name: &str) -> ExportInfo {
382 ExportInfo {
383 name: ExportName::Named(name.to_string()),
384 local_name: Some(name.to_string()),
385 is_type_only: false,
386 visibility: VisibilityTag::None,
387 expected_unused_reason: None,
388 span: oxc_span::Span::new(0, 20),
389 members: vec![],
390 is_side_effect_used: false,
391 super_class: None,
392 }
393 }
394
395 fn build_three_dir_graph() -> ModuleGraph {
398 let files = vec![
399 file(0, "/p/src/app/x.ts"),
400 file(1, "/p/src/core/a.ts"),
401 file(2, "/p/src/core/b.ts"),
402 file(3, "/p/src/mid/m.ts"),
403 ];
404 let entry_points = vec![EntryPoint {
405 path: PathBuf::from("/p/src/app/x.ts"),
406 source: EntryPointSource::PackageJsonMain,
407 }];
408 let resolved = vec![
409 ResolvedModule {
410 file_id: FileId(0),
411 path: PathBuf::from("/p/src/app/x.ts"),
412 resolved_imports: vec![named_import("../mid/m", "midFn", FileId(3))],
413 ..Default::default()
414 },
415 ResolvedModule {
416 file_id: FileId(1),
417 path: PathBuf::from("/p/src/core/a.ts"),
418 exports: vec![named_export("alpha")].into(),
419 ..Default::default()
420 },
421 ResolvedModule {
422 file_id: FileId(2),
423 path: PathBuf::from("/p/src/core/b.ts"),
424 exports: vec![named_export("beta")].into(),
425 ..Default::default()
426 },
427 ResolvedModule {
428 file_id: FileId(3),
429 path: PathBuf::from("/p/src/mid/m.ts"),
430 resolved_imports: vec![named_import("../core/a", "alpha", FileId(1))],
431 exports: vec![named_export("midFn")].into(),
432 ..Default::default()
433 },
434 ];
435 ModuleGraph::build(&resolved, &entry_points, &files)
436 }
437
438 #[test]
439 fn partition_groups_changed_files_by_module_directory() {
440 let graph = build_three_dir_graph();
441 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
443 let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
444 let dirs: Vec<&str> = paths.units.iter().map(|u| u.module_dir.as_str()).collect();
446 assert_eq!(dirs, vec!["src/app", "src/core", "src/mid"]);
447 let core = paths
449 .units
450 .iter()
451 .find(|u| u.module_dir == "src/core")
452 .expect("core unit");
453 assert_eq!(core.files, vec!["src/core/a.ts", "src/core/b.ts"]);
454 }
455
456 #[test]
457 fn order_places_definitions_before_consumers() {
458 let graph = build_three_dir_graph();
459 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
461 assert_eq!(
462 partition.order,
463 vec![
464 "/p/src/core".to_string(),
465 "/p/src/mid".to_string(),
466 "/p/src/app".to_string(),
467 ]
468 );
469 }
470
471 #[test]
472 fn independent_units_order_by_path_sort() {
473 let files = vec![file(0, "/p/src/billing/b.ts"), file(1, "/p/src/auth/a.ts")];
475 let entry_points = vec![EntryPoint {
476 path: PathBuf::from("/p/src/auth/a.ts"),
477 source: EntryPointSource::PackageJsonMain,
478 }];
479 let resolved = vec![
480 ResolvedModule {
481 file_id: FileId(0),
482 path: PathBuf::from("/p/src/billing/b.ts"),
483 ..Default::default()
484 },
485 ResolvedModule {
486 file_id: FileId(1),
487 path: PathBuf::from("/p/src/auth/a.ts"),
488 ..Default::default()
489 },
490 ];
491 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
492 let partition = graph.partition_order(&[FileId(0), FileId(1)]);
493 assert_eq!(
494 partition.order,
495 vec!["/p/src/auth".to_string(), "/p/src/billing".to_string()]
496 );
497 assert_eq!(
498 partition.independent_slices,
499 vec![
500 vec!["/p/src/auth".to_string()],
501 vec!["/p/src/billing".to_string()]
502 ],
503 "no inter-unit edge: each unit is its own slice"
504 );
505 }
506
507 #[test]
508 fn connected_units_collapse_into_one_slice() {
509 let graph = build_three_dir_graph();
512 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
513 assert_eq!(
514 partition.independent_slices,
515 vec![vec![
516 "/p/src/app".to_string(),
517 "/p/src/core".to_string(),
518 "/p/src/mid".to_string()
519 ]]
520 );
521 let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
522 assert_eq!(
523 paths.independent_slices,
524 vec![vec![
525 "src/app".to_string(),
526 "src/core".to_string(),
527 "src/mid".to_string()
528 ]]
529 );
530 }
531
532 #[test]
533 fn partition_order_is_byte_identical_across_runs() {
534 let graph = build_three_dir_graph();
535 let changed = [FileId(0), FileId(1), FileId(2), FileId(3)];
536 let first = graph.partition_order(&changed);
537 let second = graph.partition_order(&changed);
538 assert_eq!(first, second);
540 let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
543 let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
544 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
545 }
546
547 #[test]
548 fn changed_set_order_does_not_affect_result() {
549 let graph = build_three_dir_graph();
552 let a = graph.partition_order(&[FileId(3), FileId(0), FileId(2), FileId(1)]);
553 let b = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
554 assert_eq!(a, b);
555 }
556
557 #[test]
558 fn root_file_clusters_under_root_group() {
559 let files = vec![file(0, "index.ts")];
560 let entry_points = vec![EntryPoint {
561 path: PathBuf::from("index.ts"),
562 source: EntryPointSource::PackageJsonMain,
563 }];
564 let resolved = vec![ResolvedModule {
565 file_id: FileId(0),
566 path: PathBuf::from("index.ts"),
567 ..Default::default()
568 }];
569 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
570 let partition = graph.partition_order(&[FileId(0)]);
571 assert_eq!(partition.units.len(), 1);
572 assert_eq!(partition.units[0].module_dir, "");
573 }
574
575 #[test]
576 fn empty_changed_set_yields_empty_partition() {
577 let graph = build_three_dir_graph();
578 let partition = graph.partition_order(&[]);
579 assert!(partition.units.is_empty());
580 assert!(partition.order.is_empty());
581 }
582
583 #[test]
584 fn scale_300_file_multi_module_graph_is_stable() {
585 const DIRS: u32 = 30;
589 const PER_DIR: u32 = 10;
590 let mut files = Vec::new();
591 let mut resolved = Vec::new();
592 for d in 0..DIRS {
593 for f in 0..PER_DIR {
594 let id = d * PER_DIR + f;
595 let path = format!("/p/src/mod{d:02}/file{f:02}.ts");
596 files.push(file(id, &path));
597 }
598 }
599 for d in 0..DIRS {
600 for f in 0..PER_DIR {
601 let id = d * PER_DIR + f;
602 let mut module = ResolvedModule {
603 file_id: FileId(id),
604 path: PathBuf::from(format!("/p/src/mod{d:02}/file{f:02}.ts")),
605 exports: vec![named_export(&format!("e{id}"))].into(),
606 ..Default::default()
607 };
608 if f == 0 && d > 0 {
611 let dep = (d - 1) * PER_DIR;
612 module.resolved_imports =
613 vec![named_import("../prev", &format!("e{dep}"), FileId(dep))];
614 }
615 resolved.push(module);
616 }
617 }
618 let entry_points = vec![EntryPoint {
619 path: PathBuf::from("/p/src/mod00/file00.ts"),
620 source: EntryPointSource::PackageJsonMain,
621 }];
622 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
623
624 let changed: Vec<FileId> = (0..DIRS * PER_DIR).map(FileId).collect();
625 let first = graph.partition_order(&changed);
626 let second = graph.partition_order(&changed);
627 assert_eq!(first, second, "300-file partition must be stable");
628 assert_eq!(first.units.len(), DIRS as usize, "one unit per directory");
629 let expected: Vec<String> = (0..DIRS).map(|d| format!("/p/src/mod{d:02}")).collect();
631 assert_eq!(
632 first.order, expected,
633 "definitions precede consumers at scale"
634 );
635 let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
637 let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
638 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
639 }
640}