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 deprecated: false,
393 deprecated_reason: None,
394 }
395 }
396
397 fn build_three_dir_graph() -> ModuleGraph {
400 let files = vec![
401 file(0, "/p/src/app/x.ts"),
402 file(1, "/p/src/core/a.ts"),
403 file(2, "/p/src/core/b.ts"),
404 file(3, "/p/src/mid/m.ts"),
405 ];
406 let entry_points = vec![EntryPoint {
407 path: PathBuf::from("/p/src/app/x.ts"),
408 source: EntryPointSource::PackageJsonMain,
409 }];
410 let resolved = vec![
411 ResolvedModule {
412 file_id: FileId(0),
413 path: PathBuf::from("/p/src/app/x.ts"),
414 resolved_imports: vec![named_import("../mid/m", "midFn", FileId(3))],
415 ..Default::default()
416 },
417 ResolvedModule {
418 file_id: FileId(1),
419 path: PathBuf::from("/p/src/core/a.ts"),
420 exports: vec![named_export("alpha")].into(),
421 ..Default::default()
422 },
423 ResolvedModule {
424 file_id: FileId(2),
425 path: PathBuf::from("/p/src/core/b.ts"),
426 exports: vec![named_export("beta")].into(),
427 ..Default::default()
428 },
429 ResolvedModule {
430 file_id: FileId(3),
431 path: PathBuf::from("/p/src/mid/m.ts"),
432 resolved_imports: vec![named_import("../core/a", "alpha", FileId(1))],
433 exports: vec![named_export("midFn")].into(),
434 ..Default::default()
435 },
436 ];
437 ModuleGraph::build(&resolved, &entry_points, &files)
438 }
439
440 #[test]
441 fn partition_groups_changed_files_by_module_directory() {
442 let graph = build_three_dir_graph();
443 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
445 let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
446 let dirs: Vec<&str> = paths.units.iter().map(|u| u.module_dir.as_str()).collect();
448 assert_eq!(dirs, vec!["src/app", "src/core", "src/mid"]);
449 let core = paths
451 .units
452 .iter()
453 .find(|u| u.module_dir == "src/core")
454 .expect("core unit");
455 assert_eq!(core.files, vec!["src/core/a.ts", "src/core/b.ts"]);
456 }
457
458 #[test]
459 fn order_places_definitions_before_consumers() {
460 let graph = build_three_dir_graph();
461 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
463 assert_eq!(
464 partition.order,
465 vec![
466 "/p/src/core".to_string(),
467 "/p/src/mid".to_string(),
468 "/p/src/app".to_string(),
469 ]
470 );
471 }
472
473 #[test]
474 fn independent_units_order_by_path_sort() {
475 let files = vec![file(0, "/p/src/billing/b.ts"), file(1, "/p/src/auth/a.ts")];
477 let entry_points = vec![EntryPoint {
478 path: PathBuf::from("/p/src/auth/a.ts"),
479 source: EntryPointSource::PackageJsonMain,
480 }];
481 let resolved = vec![
482 ResolvedModule {
483 file_id: FileId(0),
484 path: PathBuf::from("/p/src/billing/b.ts"),
485 ..Default::default()
486 },
487 ResolvedModule {
488 file_id: FileId(1),
489 path: PathBuf::from("/p/src/auth/a.ts"),
490 ..Default::default()
491 },
492 ];
493 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
494 let partition = graph.partition_order(&[FileId(0), FileId(1)]);
495 assert_eq!(
496 partition.order,
497 vec!["/p/src/auth".to_string(), "/p/src/billing".to_string()]
498 );
499 assert_eq!(
500 partition.independent_slices,
501 vec![
502 vec!["/p/src/auth".to_string()],
503 vec!["/p/src/billing".to_string()]
504 ],
505 "no inter-unit edge: each unit is its own slice"
506 );
507 }
508
509 #[test]
510 fn connected_units_collapse_into_one_slice() {
511 let graph = build_three_dir_graph();
514 let partition = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
515 assert_eq!(
516 partition.independent_slices,
517 vec![vec![
518 "/p/src/app".to_string(),
519 "/p/src/core".to_string(),
520 "/p/src/mid".to_string()
521 ]]
522 );
523 let paths = graph.partition_order_with_paths(&partition, Path::new("/p"));
524 assert_eq!(
525 paths.independent_slices,
526 vec![vec![
527 "src/app".to_string(),
528 "src/core".to_string(),
529 "src/mid".to_string()
530 ]]
531 );
532 }
533
534 #[test]
535 fn partition_order_is_byte_identical_across_runs() {
536 let graph = build_three_dir_graph();
537 let changed = [FileId(0), FileId(1), FileId(2), FileId(3)];
538 let first = graph.partition_order(&changed);
539 let second = graph.partition_order(&changed);
540 assert_eq!(first, second);
542 let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
545 let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
546 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
547 }
548
549 #[test]
550 fn changed_set_order_does_not_affect_result() {
551 let graph = build_three_dir_graph();
554 let a = graph.partition_order(&[FileId(3), FileId(0), FileId(2), FileId(1)]);
555 let b = graph.partition_order(&[FileId(0), FileId(1), FileId(2), FileId(3)]);
556 assert_eq!(a, b);
557 }
558
559 #[test]
560 fn root_file_clusters_under_root_group() {
561 let files = vec![file(0, "index.ts")];
562 let entry_points = vec![EntryPoint {
563 path: PathBuf::from("index.ts"),
564 source: EntryPointSource::PackageJsonMain,
565 }];
566 let resolved = vec![ResolvedModule {
567 file_id: FileId(0),
568 path: PathBuf::from("index.ts"),
569 ..Default::default()
570 }];
571 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
572 let partition = graph.partition_order(&[FileId(0)]);
573 assert_eq!(partition.units.len(), 1);
574 assert_eq!(partition.units[0].module_dir, "");
575 }
576
577 #[test]
578 fn empty_changed_set_yields_empty_partition() {
579 let graph = build_three_dir_graph();
580 let partition = graph.partition_order(&[]);
581 assert!(partition.units.is_empty());
582 assert!(partition.order.is_empty());
583 }
584
585 #[test]
586 fn scale_300_file_multi_module_graph_is_stable() {
587 const DIRS: u32 = 30;
591 const PER_DIR: u32 = 10;
592 let mut files = Vec::new();
593 let mut resolved = Vec::new();
594 for d in 0..DIRS {
595 for f in 0..PER_DIR {
596 let id = d * PER_DIR + f;
597 let path = format!("/p/src/mod{d:02}/file{f:02}.ts");
598 files.push(file(id, &path));
599 }
600 }
601 for d in 0..DIRS {
602 for f in 0..PER_DIR {
603 let id = d * PER_DIR + f;
604 let mut module = ResolvedModule {
605 file_id: FileId(id),
606 path: PathBuf::from(format!("/p/src/mod{d:02}/file{f:02}.ts")),
607 exports: vec![named_export(&format!("e{id}"))].into(),
608 ..Default::default()
609 };
610 if f == 0 && d > 0 {
613 let dep = (d - 1) * PER_DIR;
614 module.resolved_imports =
615 vec![named_import("../prev", &format!("e{dep}"), FileId(dep))];
616 }
617 resolved.push(module);
618 }
619 }
620 let entry_points = vec![EntryPoint {
621 path: PathBuf::from("/p/src/mod00/file00.ts"),
622 source: EntryPointSource::PackageJsonMain,
623 }];
624 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
625
626 let changed: Vec<FileId> = (0..DIRS * PER_DIR).map(FileId).collect();
627 let first = graph.partition_order(&changed);
628 let second = graph.partition_order(&changed);
629 assert_eq!(first, second, "300-file partition must be stable");
630 assert_eq!(first.units.len(), DIRS as usize, "one unit per directory");
631 let expected: Vec<String> = (0..DIRS).map(|d| format!("/p/src/mod{d:02}")).collect();
633 assert_eq!(
634 first.order, expected,
635 "definitions precede consumers at scale"
636 );
637 let p1 = graph.partition_order_with_paths(&first, Path::new("/p"));
639 let p2 = graph.partition_order_with_paths(&second, Path::new("/p"));
640 assert_eq!(format!("{p1:?}"), format!("{p2:?}"));
641 }
642}