1use crate::{Exploded, ManifestRow, naming};
40use std::collections::{BTreeMap, BTreeSet, HashMap};
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SplitPlan {
45 pub header: Vec<usize>,
47 pub root_items: Vec<usize>,
53 pub mods: Vec<usize>,
58 pub parts: Vec<Part>,
60 pub oversized: Vec<Oversized>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Part {
67 pub name: String,
71 pub chunk_indices: Vec<usize>,
73 pub loc: usize,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Oversized {
80 pub chunk_index: usize,
81 pub name: String,
82 pub kind: String,
83 pub loc: usize,
84 pub recoverable: bool,
89}
90
91impl SplitPlan {
92 pub fn is_complete(&self) -> bool {
93 self.oversized.is_empty()
94 }
95}
96
97struct Item {
98 chunk_index: usize,
99 name: String,
100 kind: String,
101 loc: usize,
102 refs: Vec<String>,
103 title: Option<String>,
105 section: usize,
107}
108
109impl Item {
110 fn new(row: &ManifestRow, chunk_text: &str, section: usize) -> Self {
111 let lead = row.span.start.saturating_sub(row.byte_range.start);
113 Self {
114 chunk_index: row.chunk_index,
115 name: row.name.clone(),
116 kind: row.kind.clone(),
117 loc: row.loc,
118 refs: row.adjacency_hint.clone(),
119 title: chunk_text.get(..lead).and_then(naming::banner_title),
120 section,
121 }
122 }
123}
124
125pub fn plan_split(exploded: &Exploded, max_loc: usize) -> SplitPlan {
127 let mut header = Vec::new();
128 let mut root_items = Vec::new();
129 let mut mods = Vec::new();
130 let mut oversized = Vec::new();
131 let mut items: Vec<Item> = Vec::new();
132 let owned_types = inherent_type_names(exploded);
133 let lexical_items = lexical_root_items(exploded, &owned_types);
134 let mut section = 0;
135
136 for row in &exploded.manifest.rows {
137 let chunk_text = exploded.chunks[row.chunk_index].text.as_str();
138 let lead = row.span.start.saturating_sub(row.byte_range.start);
139 if chunk_text
140 .get(..lead)
141 .and_then(naming::banner_title)
142 .is_some()
143 {
144 section = row.chunk_index + 1;
145 }
146 match row.kind.as_str() {
147 _ if lexical_items.contains(&row.chunk_index) => root_items.push(row.chunk_index),
148 "preamble" => header.push(row.chunk_index),
149 "use" | "extern_crate" => {
150 if is_reexport(chunk_text) {
151 root_items.push(row.chunk_index);
152 } else {
153 header.push(row.chunk_index);
154 }
155 }
156 "mod" => match mod_body(chunk_text) {
157 Some(true) => mods.push(row.chunk_index),
158 Some(false) => root_items.push(row.chunk_index),
159 None => items.push(Item::new(row, chunk_text, section)),
161 },
162 _ if row.loc >= max_loc => oversized.push(Oversized {
163 chunk_index: row.chunk_index,
164 name: row.name.clone(),
165 kind: row.kind.clone(),
166 loc: row.loc,
167 recoverable: row.kind == "mod",
168 }),
169 "struct" | "enum" | "union" if owned_types.contains(&row.name) => {
170 root_items.push(row.chunk_index);
171 }
172 "impl" if has_owner_local_members(chunk_text) => root_items.push(row.chunk_index),
173 _ => items.push(Item::new(row, chunk_text, section)),
174 }
175 }
176
177 items.retain(|item| {
180 if item.loc >= max_loc {
181 oversized.push(Oversized {
182 chunk_index: item.chunk_index,
183 name: item.name.clone(),
184 kind: item.kind.clone(),
185 loc: item.loc,
186 recoverable: item.kind == "mod",
187 });
188 false
189 } else {
190 true
191 }
192 });
193 oversized.sort_by_key(|over| over.chunk_index);
194
195 let parts = if items.is_empty() {
196 Vec::new()
197 } else {
198 cluster(&items, max_loc)
199 };
200 SplitPlan {
201 header,
202 root_items,
203 mods,
204 parts,
205 oversized,
206 }
207}
208
209fn has_owner_local_members(text: &str) -> bool {
212 let Ok(syn::Item::Impl(item)) = syn::parse_str(text) else {
213 return false;
214 };
215 item.trait_.is_none()
216 && item.items.iter().any(|member| {
217 let vis = match member {
218 syn::ImplItem::Fn(item) => &item.vis,
219 syn::ImplItem::Const(item) => &item.vis,
220 syn::ImplItem::Type(item) => &item.vis,
221 _ => return false,
222 };
223 matches!(vis, syn::Visibility::Restricted(vis) if vis.path.is_ident("self"))
224 })
225}
226
227fn lexical_root_items(exploded: &Exploded, owners: &BTreeSet<String>) -> BTreeSet<usize> {
235 let macros: BTreeMap<usize, String> = exploded
236 .manifest
237 .rows
238 .iter()
239 .filter(|r| r.kind == "macro_rules")
240 .map(|r| (r.chunk_index, r.name.clone()))
241 .collect();
242 if macros.is_empty() {
243 return BTreeSet::new();
244 }
245 let names: BTreeSet<&str> = macros.values().map(String::as_str).collect();
246 let mut consumers: BTreeMap<String, Vec<usize>> = BTreeMap::new();
247 let mut dependencies: BTreeMap<usize, Vec<String>> = BTreeMap::new();
248 let mut pending = BTreeSet::new();
249 for row in &exploded.manifest.rows {
250 if matches!(row.kind.as_str(), "preamble" | "use" | "extern_crate") {
251 continue;
252 }
253 let mut refs = crate::referenced_idents(&exploded.chunks[row.chunk_index].text)
254 .unwrap_or_else(|| names.iter().map(|s| (*s).to_owned()).collect());
255 if let Some(name) = macros.get(&row.chunk_index) {
256 refs.insert(name.clone());
257 }
258 let refs: Vec<String> = refs
259 .into_iter()
260 .filter(|s| names.contains(s.as_str()))
261 .collect();
262 if (matches!(row.kind.as_str(), "struct" | "enum" | "union") && owners.contains(&row.name))
263 || (row.kind == "impl"
264 && has_owner_local_members(&exploded.chunks[row.chunk_index].text))
265 {
266 pending.extend(refs.iter().cloned());
267 }
268 for name in &refs {
269 consumers
270 .entry(name.clone())
271 .or_default()
272 .push(row.chunk_index);
273 }
274 dependencies.insert(row.chunk_index, refs);
275 }
276 let mut seen = BTreeSet::new();
277 let mut kept = BTreeSet::new();
278 while let Some(name) = pending.pop_first() {
279 if !seen.insert(name.clone()) {
280 continue;
281 }
282 for &index in &consumers[&name] {
283 if kept.insert(index) {
284 pending.extend(dependencies[&index].iter().cloned());
285 }
286 }
287 }
288 kept
289}
290
291fn inherent_type_names(exploded: &Exploded) -> std::collections::BTreeSet<String> {
296 exploded
297 .manifest
298 .rows
299 .iter()
300 .filter(|row| row.kind == "impl")
301 .filter_map(|row| {
302 let syn::Item::Impl(item) =
303 syn::parse_str(&exploded.chunks[row.chunk_index].text).ok()?
304 else {
305 return None;
306 };
307 if item.trait_.is_some() {
308 return None;
309 }
310 let syn::Type::Path(ty) = item.self_ty.as_ref() else {
311 return None;
312 };
313 if ty.qself.is_some() || ty.path.leading_colon.is_some() {
314 return None;
315 }
316 let segments = &ty.path.segments;
317 if segments.len() == 1 || (segments.len() == 2 && segments[0].ident == "self") {
318 Some(segments.last()?.ident.to_string())
319 } else {
320 None
321 }
322 })
323 .collect()
324}
325
326fn is_reexport(chunk_text: &str) -> bool {
331 match syn::parse_str::<syn::Item>(chunk_text) {
332 Ok(syn::Item::Use(item)) => !matches!(item.vis, syn::Visibility::Inherited),
333 Ok(syn::Item::ExternCrate(item)) => !matches!(item.vis, syn::Visibility::Inherited),
334 _ => false,
337 }
338}
339
340fn mod_body(chunk_text: &str) -> Option<bool> {
344 match syn::parse_str::<syn::Item>(chunk_text) {
345 Ok(syn::Item::Mod(item)) => Some(item.content.is_some()),
346 _ => None,
347 }
348}
349
350fn cluster(items: &[Item], max_loc: usize) -> Vec<Part> {
361 let n = items.len();
362
363 let mut name_to_item: HashMap<(usize, &str), usize> = HashMap::new();
366 for (i, item) in items.iter().enumerate() {
367 name_to_item
368 .entry((item.section, item.name.as_str()))
369 .or_insert(i);
370 }
371
372 let mut weights: HashMap<(usize, usize), usize> = HashMap::new();
374 for (i, item) in items.iter().enumerate() {
375 for reference in &item.refs {
376 if let Some(&j) = name_to_item.get(&(item.section, reference.as_str()))
377 && i != j
378 {
379 *weights.entry((i.min(j), i.max(j))).or_insert(0) += 1;
380 }
381 }
382 }
383 let mut edges: Vec<((usize, usize), usize)> = weights.into_iter().collect();
384 edges.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
386
387 let mut component: Vec<usize> = (0..n).collect();
390 for &((i, j), _weight) in &edges {
391 let ri = find(&mut component, i);
392 let rj = find(&mut component, j);
393 if ri != rj {
394 component[rj] = ri;
395 }
396 }
397 let mut component_loc: HashMap<usize, usize> = HashMap::new();
398 for (i, item) in items.iter().enumerate() {
399 let root = find(&mut component, i);
400 *component_loc.entry(root).or_insert(0) += item.loc;
401 }
402 let target = (max_loc / 2).max(1);
403 let share = |component_root: usize, component_loc: &HashMap<usize, usize>| -> usize {
404 let total = component_loc[&component_root];
405 if total <= target {
406 total
407 } else {
408 total.div_ceil(total.div_ceil(target))
409 }
410 };
411
412 let mut parent: Vec<usize> = (0..n).collect();
413 let mut group_loc: Vec<usize> = items.iter().map(|item| item.loc).collect();
414 for ((i, j), _weight) in edges {
415 let cap = share(find(&mut component, i), &component_loc);
416 let ri = find(&mut parent, i);
417 let rj = find(&mut parent, j);
418 if ri != rj && group_loc[ri] + group_loc[rj] <= cap {
419 parent[rj] = ri;
420 group_loc[ri] += group_loc[rj];
421 }
422 }
423
424 let mut first_chunk: HashMap<usize, usize> = HashMap::new();
429 for (i, item) in items.iter().enumerate() {
430 let root = find(&mut parent, i);
431 let first = first_chunk.entry(root).or_insert(usize::MAX);
432 *first = (*first).min(item.chunk_index);
433 }
434 let mut by_component: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
435 for i in 0..n {
436 if find(&mut parent, i) == i {
437 let component_root = find(&mut component, i);
438 by_component.entry(component_root).or_default().push(i);
439 }
440 }
441 for (component_root, mut cluster_roots) in by_component {
442 let cap = share(component_root, &component_loc);
443 cluster_roots.sort_by_key(|root| first_chunk[root]);
444 let mut accumulator: Option<usize> = None;
445 for root in cluster_roots {
446 match accumulator {
447 Some(acc) if group_loc[acc] + group_loc[root] <= cap => {
448 parent[root] = acc;
449 group_loc[acc] += group_loc[root];
450 }
451 _ => accumulator = Some(root),
452 }
453 }
454 }
455
456 let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
458 for i in 0..n {
459 let root = find(&mut parent, i);
460 groups.entry(root).or_default().push(i);
461 }
462
463 let mut parts: Vec<Part> = groups
464 .into_values()
465 .map(|members| {
466 let mut chunk_indices: Vec<usize> =
467 members.iter().map(|&i| items[i].chunk_index).collect();
468 chunk_indices.sort_unstable();
469 let loc = members.iter().map(|&i| items[i].loc).sum();
470 Part {
471 name: cluster_name(&members, items),
472 chunk_indices,
473 loc,
474 }
475 })
476 .collect();
477
478 parts.sort_by_key(|part| part.chunk_indices.first().copied().unwrap_or(0));
479 dedupe_names(&mut parts);
480 parts
481}
482
483fn find(parent: &mut [usize], mut x: usize) -> usize {
484 while parent[x] != x {
485 parent[x] = parent[parent[x]]; x = parent[x];
487 }
488 x
489}
490
491fn cluster_name(members: &[usize], items: &[Item]) -> String {
495 let banner = members
496 .iter()
497 .min_by_key(|&&i| (items[i].title.is_none(), items[i].chunk_index))
498 .and_then(|&i| items[i].title.as_deref());
499 if let Some(title) = banner {
500 return naming::module_name(title);
501 }
502 let nameable = |kind: &str| {
503 matches!(
504 kind,
505 "fn" | "struct"
506 | "enum"
507 | "const"
508 | "static"
509 | "trait"
510 | "trait_alias"
511 | "type"
512 | "union"
513 | "mod"
514 )
515 };
516 let dominant = members
517 .iter()
518 .filter(|&&i| nameable(&items[i].kind))
519 .max_by_key(|&&i| items[i].loc)
520 .or_else(|| members.iter().max_by_key(|&&i| items[i].loc));
521 match dominant {
522 Some(&i) => naming::module_name(&items[i].name),
523 None => "group".to_owned(),
524 }
525}
526
527fn dedupe_names(parts: &mut [Part]) {
528 let mut seen: HashMap<String, usize> = HashMap::new();
529 for part in parts.iter_mut() {
530 let count = seen.entry(part.name.clone()).or_insert(0);
531 *count += 1;
532 if *count > 1 {
533 part.name = format!("{}_{}", part.name.trim_end_matches('_'), *count);
535 }
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use crate::explode;
543
544 fn fns(defs: &[(&str, &[&str])]) -> String {
545 let mut src = String::new();
547 for (name, calls) in defs {
548 src.push_str(&format!("fn {name}() {{\n"));
549 for c in *calls {
550 src.push_str(&format!(" {c}();\n"));
551 }
552 src.push_str("}\n\n");
553 }
554 src
555 }
556
557 #[test]
558 fn unrelated_items_become_separate_files_not_merged() {
559 let src = fns(&[
561 ("a", &[]),
562 ("b", &[]),
563 ("c", &[]),
564 ("d", &[]),
565 ("e", &[]),
566 ("f", &[]),
567 ("g", &[]),
568 ("h", &[]),
569 ("i", &[]),
570 ("j", &[]),
571 ]);
572 let exploded = explode(&src).unwrap();
573 let plan = plan_split(&exploded, 10_000);
575 assert_eq!(plan.parts.len(), 10, "unrelated items must not be merged");
576 }
577
578 #[test]
579 fn related_items_cluster_together_unrelated_stay_apart() {
580 let src = fns(&[
582 ("a1", &["a2"]),
583 ("a2", &["a3"]),
584 ("a3", &[]),
585 ("b1", &["b2"]),
586 ("b2", &[]),
587 ("c", &[]),
588 ]);
589 let exploded = explode(&src).unwrap();
590 let plan = plan_split(&exploded, 10_000);
591 assert_eq!(plan.parts.len(), 3, "two clusters + one loner");
592 let sizes: Vec<usize> = plan.parts.iter().map(|p| p.chunk_indices.len()).collect();
593 assert!(sizes.contains(&3) && sizes.contains(&2) && sizes.contains(&1));
594 }
595
596 #[test]
597 fn a_cohesive_group_over_the_ceiling_is_split() {
598 let src = fns(&[
600 ("a", &["b"]),
601 ("b", &["c"]),
602 ("c", &["d"]),
603 ("d", &["e"]),
604 ("e", &[]),
605 ]);
606 let exploded = explode(&src).unwrap();
607 let plan = plan_split(&exploded, 10);
608 assert!(plan.parts.len() >= 2, "ceiling forces a split");
609 for part in &plan.parts {
610 assert!(part.loc < 10, "part over ceiling: {}", part.loc);
611 }
612 }
613
614 #[test]
615 fn imports_go_to_the_header_and_clusters_are_named() {
616 let src = "use std::fmt;\n\nfn alpha() { beta(); }\n\nfn beta() {}\n";
617 let exploded = explode(src).unwrap();
618 let plan = plan_split(&exploded, 10_000);
619 assert_eq!(plan.header.len(), 1);
620 assert_eq!(plan.parts.len(), 1, "alpha+beta cluster");
621 assert!(!plan.parts[0].name.is_empty());
622 assert!(
623 plan.parts[0]
624 .name
625 .chars()
626 .all(|c| c.is_ascii_lowercase() || c == '_')
627 );
628 }
629
630 #[test]
631 fn an_oversized_leaf_is_reported_not_forced() {
632 let src =
633 "fn big() {\n let _a = 1;\n let _b = 2;\n let _c = 3;\n let _d = 4;\n}\n";
634 let exploded = explode(src).unwrap();
635 let plan = plan_split(&exploded, 5);
636 assert_eq!(plan.oversized.len(), 1);
637 assert!(!plan.oversized[0].recoverable);
638 assert!(plan.parts.is_empty());
639 }
640
641 #[test]
644 fn reexports_mod_decls_and_body_mods_are_kept_out_of_clustering() {
645 let src = "use std::fmt;\n\nmod helpers;\n\npub use helpers::Helper;\n\n\
646 pub fn api() -> i32 {\n 1\n}\n\n\
647 #[cfg(test)]\nmod tests {\n fn smoke() {}\n}\n";
648 let exploded = explode(src).unwrap();
649 let plan = plan_split(&exploded, 10_000);
650
651 let text = |indices: &[usize]| -> String {
652 indices
653 .iter()
654 .map(|&i| exploded.chunks[i].text.as_str())
655 .collect()
656 };
657 let header = text(&plan.header);
658 assert!(header.contains("use std::fmt;"), "plain import -> header");
659 assert!(
660 !header.contains("pub use"),
661 "re-export is not a header import"
662 );
663
664 let root_items = text(&plan.root_items);
665 assert!(
666 root_items.contains("mod helpers;"),
667 "content-less mod decl stays at the root"
668 );
669 assert!(
670 root_items.contains("pub use helpers::Helper;"),
671 "pub use re-export stays at the root"
672 );
673
674 assert_eq!(plan.mods.len(), 1, "body mod is extracted, never clustered");
675 assert!(text(&plan.mods).contains("#[cfg(test)]"));
676 assert_eq!(plan.parts.len(), 1, "only `api` is left to cluster");
677 assert!(plan.oversized.is_empty());
678 }
679
680 #[test]
683 fn a_big_connected_component_is_partitioned_not_packed_to_the_ceiling() {
684 let mut src = String::new();
688 for i in 0..12 {
689 let call = if i < 11 {
690 format!(" chain{}();\n", i + 1)
691 } else {
692 String::new()
693 };
694 src.push_str(&format!(
695 "fn chain{i}() {{\n{call} let _a = 1;\n let _b = 2;\n let _c = 3;\n \
696 let _d = 4;\n let _e = 5;\n let _f = 6;\n let _g = 7;\n}}\n\n"
697 ));
698 }
699 let exploded = explode(&src).unwrap();
700 let plan = plan_split(&exploded, 100);
701
702 assert!(
703 plan.parts.len() >= 3,
704 "a ~132-LOC component under a 100 ceiling must give >= 3 parts, got {}: {:?}",
705 plan.parts.len(),
706 plan.parts.iter().map(|p| p.loc).collect::<Vec<_>>()
707 );
708 for part in &plan.parts {
709 assert!(
710 part.loc <= 50,
711 "part must stay near the balanced share (<= half the ceiling), got {}",
712 part.loc
713 );
714 }
715 }
716
717 #[test]
718 fn satellites_of_a_full_hub_consolidate_instead_of_one_file_each() {
719 let mut src = String::from("fn hub() {}\n\n");
723 for i in 0..20 {
724 src.push_str(&format!(
725 "fn sat{i}() {{\n hub();\n let _x = {i};\n}}\n\n"
726 ));
727 }
728 let exploded = explode(&src).unwrap();
729 let plan = plan_split(&exploded, 60);
730
731 assert!(
732 plan.parts.len() <= 6,
733 "satellites must consolidate, got {} parts",
734 plan.parts.len()
735 );
736 for part in &plan.parts {
737 assert!(part.loc <= 30, "part over the balanced share: {}", part.loc);
738 }
739 }
740}