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