1use crate::Exploded;
32use std::collections::{BTreeMap, HashMap};
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SplitPlan {
37 pub header: Vec<usize>,
39 pub root_items: Vec<usize>,
44 pub mods: Vec<usize>,
48 pub parts: Vec<Part>,
50 pub oversized: Vec<Oversized>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Part {
57 pub name: String,
59 pub chunk_indices: Vec<usize>,
61 pub loc: usize,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Oversized {
68 pub chunk_index: usize,
69 pub name: String,
70 pub kind: String,
71 pub loc: usize,
72 pub recoverable: bool,
77}
78
79impl SplitPlan {
80 pub fn is_complete(&self) -> bool {
81 self.oversized.is_empty()
82 }
83}
84
85struct Item {
86 chunk_index: usize,
87 name: String,
88 kind: String,
89 loc: usize,
90 refs: Vec<String>,
91}
92
93pub fn plan_split(exploded: &Exploded, max_loc: usize) -> SplitPlan {
95 let mut header = Vec::new();
96 let mut root_items = Vec::new();
97 let mut mods = Vec::new();
98 let mut oversized = Vec::new();
99 let mut items: Vec<Item> = Vec::new();
100
101 for row in &exploded.manifest.rows {
102 let chunk_text = exploded.chunks[row.chunk_index].text.as_str();
103 match row.kind.as_str() {
104 "preamble" => header.push(row.chunk_index),
105 "use" | "extern_crate" => {
106 if is_reexport(chunk_text) {
107 root_items.push(row.chunk_index);
108 } else {
109 header.push(row.chunk_index);
110 }
111 }
112 "mod" => match mod_body(chunk_text) {
113 Some(true) => mods.push(row.chunk_index),
114 Some(false) => root_items.push(row.chunk_index),
115 None => items.push(Item {
117 chunk_index: row.chunk_index,
118 name: row.name.clone(),
119 kind: row.kind.clone(),
120 loc: row.loc,
121 refs: row.adjacency_hint.clone(),
122 }),
123 },
124 _ if row.loc >= max_loc => oversized.push(Oversized {
125 chunk_index: row.chunk_index,
126 name: row.name.clone(),
127 kind: row.kind.clone(),
128 loc: row.loc,
129 recoverable: row.kind == "mod",
130 }),
131 _ => items.push(Item {
132 chunk_index: row.chunk_index,
133 name: row.name.clone(),
134 kind: row.kind.clone(),
135 loc: row.loc,
136 refs: row.adjacency_hint.clone(),
137 }),
138 }
139 }
140
141 items.retain(|item| {
144 if item.loc >= max_loc {
145 oversized.push(Oversized {
146 chunk_index: item.chunk_index,
147 name: item.name.clone(),
148 kind: item.kind.clone(),
149 loc: item.loc,
150 recoverable: item.kind == "mod",
151 });
152 false
153 } else {
154 true
155 }
156 });
157 oversized.sort_by_key(|over| over.chunk_index);
158
159 let parts = if items.is_empty() {
160 Vec::new()
161 } else {
162 cluster(&items, max_loc)
163 };
164 SplitPlan {
165 header,
166 root_items,
167 mods,
168 parts,
169 oversized,
170 }
171}
172
173fn is_reexport(chunk_text: &str) -> bool {
178 match syn::parse_str::<syn::Item>(chunk_text) {
179 Ok(syn::Item::Use(item)) => !matches!(item.vis, syn::Visibility::Inherited),
180 Ok(syn::Item::ExternCrate(item)) => !matches!(item.vis, syn::Visibility::Inherited),
181 _ => false,
184 }
185}
186
187fn mod_body(chunk_text: &str) -> Option<bool> {
191 match syn::parse_str::<syn::Item>(chunk_text) {
192 Ok(syn::Item::Mod(item)) => Some(item.content.is_some()),
193 _ => None,
194 }
195}
196
197fn cluster(items: &[Item], max_loc: usize) -> Vec<Part> {
208 let n = items.len();
209
210 let mut name_to_item: HashMap<&str, usize> = HashMap::new();
212 for (i, item) in items.iter().enumerate() {
213 name_to_item.entry(item.name.as_str()).or_insert(i);
214 }
215
216 let mut weights: HashMap<(usize, usize), usize> = HashMap::new();
218 for (i, item) in items.iter().enumerate() {
219 for reference in &item.refs {
220 if let Some(&j) = name_to_item.get(reference.as_str())
221 && i != j
222 {
223 *weights.entry((i.min(j), i.max(j))).or_insert(0) += 1;
224 }
225 }
226 }
227 let mut edges: Vec<((usize, usize), usize)> = weights.into_iter().collect();
228 edges.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
230
231 let mut component: Vec<usize> = (0..n).collect();
234 for &((i, j), _weight) in &edges {
235 let ri = find(&mut component, i);
236 let rj = find(&mut component, j);
237 if ri != rj {
238 component[rj] = ri;
239 }
240 }
241 let mut component_loc: HashMap<usize, usize> = HashMap::new();
242 for (i, item) in items.iter().enumerate() {
243 let root = find(&mut component, i);
244 *component_loc.entry(root).or_insert(0) += item.loc;
245 }
246 let target = (max_loc / 2).max(1);
247 let share = |component_root: usize, component_loc: &HashMap<usize, usize>| -> usize {
248 let total = component_loc[&component_root];
249 if total <= target {
250 total
251 } else {
252 total.div_ceil(total.div_ceil(target))
253 }
254 };
255
256 let mut parent: Vec<usize> = (0..n).collect();
257 let mut group_loc: Vec<usize> = items.iter().map(|item| item.loc).collect();
258 for ((i, j), _weight) in edges {
259 let cap = share(find(&mut component, i), &component_loc);
260 let ri = find(&mut parent, i);
261 let rj = find(&mut parent, j);
262 if ri != rj && group_loc[ri] + group_loc[rj] <= cap {
263 parent[rj] = ri;
264 group_loc[ri] += group_loc[rj];
265 }
266 }
267
268 let mut first_chunk: HashMap<usize, usize> = HashMap::new();
273 for (i, item) in items.iter().enumerate() {
274 let root = find(&mut parent, i);
275 let first = first_chunk.entry(root).or_insert(usize::MAX);
276 *first = (*first).min(item.chunk_index);
277 }
278 let mut by_component: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
279 for i in 0..n {
280 if find(&mut parent, i) == i {
281 let component_root = find(&mut component, i);
282 by_component.entry(component_root).or_default().push(i);
283 }
284 }
285 for (component_root, mut cluster_roots) in by_component {
286 let cap = share(component_root, &component_loc);
287 cluster_roots.sort_by_key(|root| first_chunk[root]);
288 let mut accumulator: Option<usize> = None;
289 for root in cluster_roots {
290 match accumulator {
291 Some(acc) if group_loc[acc] + group_loc[root] <= cap => {
292 parent[root] = acc;
293 group_loc[acc] += group_loc[root];
294 }
295 _ => accumulator = Some(root),
296 }
297 }
298 }
299
300 let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
302 for i in 0..n {
303 let root = find(&mut parent, i);
304 groups.entry(root).or_default().push(i);
305 }
306
307 let mut parts: Vec<Part> = groups
308 .into_values()
309 .map(|members| {
310 let mut chunk_indices: Vec<usize> =
311 members.iter().map(|&i| items[i].chunk_index).collect();
312 chunk_indices.sort_unstable();
313 let loc = members.iter().map(|&i| items[i].loc).sum();
314 Part {
315 name: cluster_name(&members, items),
316 chunk_indices,
317 loc,
318 }
319 })
320 .collect();
321
322 parts.sort_by_key(|part| part.chunk_indices.first().copied().unwrap_or(0));
323 dedupe_names(&mut parts);
324 parts
325}
326
327fn find(parent: &mut [usize], mut x: usize) -> usize {
328 while parent[x] != x {
329 parent[x] = parent[parent[x]]; x = parent[x];
331 }
332 x
333}
334
335fn cluster_name(members: &[usize], items: &[Item]) -> String {
337 let nameable = |kind: &str| {
338 matches!(
339 kind,
340 "fn" | "struct"
341 | "enum"
342 | "const"
343 | "static"
344 | "trait"
345 | "trait_alias"
346 | "type"
347 | "union"
348 | "mod"
349 )
350 };
351 let dominant = members
352 .iter()
353 .filter(|&&i| nameable(&items[i].kind))
354 .max_by_key(|&&i| items[i].loc)
355 .or_else(|| members.iter().max_by_key(|&&i| items[i].loc));
356 match dominant {
357 Some(&i) => sanitize(&items[i].name),
358 None => "group".to_owned(),
359 }
360}
361
362fn sanitize(name: &str) -> String {
363 let mut out: String = name
364 .chars()
365 .map(|c| {
366 if c.is_ascii_alphanumeric() {
367 c.to_ascii_lowercase()
368 } else {
369 '_'
370 }
371 })
372 .collect();
373 while out.contains("__") {
374 out = out.replace("__", "_");
375 }
376 let out = out.trim_matches('_').to_owned();
377 if out.is_empty() || out.chars().next().unwrap().is_ascii_digit() {
378 format!("m_{out}")
379 } else {
380 out
381 }
382}
383
384fn dedupe_names(parts: &mut [Part]) {
385 let mut seen: HashMap<String, usize> = HashMap::new();
386 for part in parts.iter_mut() {
387 let count = seen.entry(part.name.clone()).or_insert(0);
388 *count += 1;
389 if *count > 1 {
390 part.name = format!("{}_{}", part.name, *count);
391 }
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::explode;
399
400 fn fns(defs: &[(&str, &[&str])]) -> String {
401 let mut src = String::new();
403 for (name, calls) in defs {
404 src.push_str(&format!("fn {name}() {{\n"));
405 for c in *calls {
406 src.push_str(&format!(" {c}();\n"));
407 }
408 src.push_str("}\n\n");
409 }
410 src
411 }
412
413 #[test]
414 fn unrelated_items_become_separate_files_not_merged() {
415 let src = fns(&[
417 ("a", &[]),
418 ("b", &[]),
419 ("c", &[]),
420 ("d", &[]),
421 ("e", &[]),
422 ("f", &[]),
423 ("g", &[]),
424 ("h", &[]),
425 ("i", &[]),
426 ("j", &[]),
427 ]);
428 let exploded = explode(&src).unwrap();
429 let plan = plan_split(&exploded, 10_000);
431 assert_eq!(plan.parts.len(), 10, "unrelated items must not be merged");
432 }
433
434 #[test]
435 fn related_items_cluster_together_unrelated_stay_apart() {
436 let src = fns(&[
438 ("a1", &["a2"]),
439 ("a2", &["a3"]),
440 ("a3", &[]),
441 ("b1", &["b2"]),
442 ("b2", &[]),
443 ("c", &[]),
444 ]);
445 let exploded = explode(&src).unwrap();
446 let plan = plan_split(&exploded, 10_000);
447 assert_eq!(plan.parts.len(), 3, "two clusters + one loner");
448 let sizes: Vec<usize> = plan.parts.iter().map(|p| p.chunk_indices.len()).collect();
449 assert!(sizes.contains(&3) && sizes.contains(&2) && sizes.contains(&1));
450 }
451
452 #[test]
453 fn a_cohesive_group_over_the_ceiling_is_split() {
454 let src = fns(&[
456 ("a", &["b"]),
457 ("b", &["c"]),
458 ("c", &["d"]),
459 ("d", &["e"]),
460 ("e", &[]),
461 ]);
462 let exploded = explode(&src).unwrap();
463 let plan = plan_split(&exploded, 10);
464 assert!(plan.parts.len() >= 2, "ceiling forces a split");
465 for part in &plan.parts {
466 assert!(part.loc < 10, "part over ceiling: {}", part.loc);
467 }
468 }
469
470 #[test]
471 fn imports_go_to_the_header_and_clusters_are_named() {
472 let src = "use std::fmt;\n\nfn alpha() { beta(); }\n\nfn beta() {}\n";
473 let exploded = explode(src).unwrap();
474 let plan = plan_split(&exploded, 10_000);
475 assert_eq!(plan.header.len(), 1);
476 assert_eq!(plan.parts.len(), 1, "alpha+beta cluster");
477 assert!(!plan.parts[0].name.is_empty());
478 assert!(
479 plan.parts[0]
480 .name
481 .chars()
482 .all(|c| c.is_ascii_lowercase() || c == '_')
483 );
484 }
485
486 #[test]
487 fn an_oversized_leaf_is_reported_not_forced() {
488 let src =
489 "fn big() {\n let _a = 1;\n let _b = 2;\n let _c = 3;\n let _d = 4;\n}\n";
490 let exploded = explode(src).unwrap();
491 let plan = plan_split(&exploded, 5);
492 assert_eq!(plan.oversized.len(), 1);
493 assert!(!plan.oversized[0].recoverable);
494 assert!(plan.parts.is_empty());
495 }
496
497 #[test]
500 fn reexports_mod_decls_and_body_mods_are_kept_out_of_clustering() {
501 let src = "use std::fmt;\n\nmod helpers;\n\npub use helpers::Helper;\n\n\
502 pub fn api() -> i32 {\n 1\n}\n\n\
503 #[cfg(test)]\nmod tests {\n fn smoke() {}\n}\n";
504 let exploded = explode(src).unwrap();
505 let plan = plan_split(&exploded, 10_000);
506
507 let text = |indices: &[usize]| -> String {
508 indices
509 .iter()
510 .map(|&i| exploded.chunks[i].text.as_str())
511 .collect()
512 };
513 let header = text(&plan.header);
514 assert!(header.contains("use std::fmt;"), "plain import -> header");
515 assert!(
516 !header.contains("pub use"),
517 "re-export is not a header import"
518 );
519
520 let root_items = text(&plan.root_items);
521 assert!(
522 root_items.contains("mod helpers;"),
523 "content-less mod decl stays at the root"
524 );
525 assert!(
526 root_items.contains("pub use helpers::Helper;"),
527 "pub use re-export stays at the root"
528 );
529
530 assert_eq!(plan.mods.len(), 1, "body mod is extracted, never clustered");
531 assert!(text(&plan.mods).contains("#[cfg(test)]"));
532 assert_eq!(plan.parts.len(), 1, "only `api` is left to cluster");
533 assert!(plan.oversized.is_empty());
534 }
535
536 #[test]
539 fn a_big_connected_component_is_partitioned_not_packed_to_the_ceiling() {
540 let mut src = String::new();
544 for i in 0..12 {
545 let call = if i < 11 {
546 format!(" chain{}();\n", i + 1)
547 } else {
548 String::new()
549 };
550 src.push_str(&format!(
551 "fn chain{i}() {{\n{call} let _a = 1;\n let _b = 2;\n let _c = 3;\n \
552 let _d = 4;\n let _e = 5;\n let _f = 6;\n let _g = 7;\n}}\n\n"
553 ));
554 }
555 let exploded = explode(&src).unwrap();
556 let plan = plan_split(&exploded, 100);
557
558 assert!(
559 plan.parts.len() >= 3,
560 "a ~132-LOC component under a 100 ceiling must give >= 3 parts, got {}: {:?}",
561 plan.parts.len(),
562 plan.parts.iter().map(|p| p.loc).collect::<Vec<_>>()
563 );
564 for part in &plan.parts {
565 assert!(
566 part.loc <= 50,
567 "part must stay near the balanced share (<= half the ceiling), got {}",
568 part.loc
569 );
570 }
571 }
572
573 #[test]
574 fn satellites_of_a_full_hub_consolidate_instead_of_one_file_each() {
575 let mut src = String::from("fn hub() {}\n\n");
579 for i in 0..20 {
580 src.push_str(&format!(
581 "fn sat{i}() {{\n hub();\n let _x = {i};\n}}\n\n"
582 ));
583 }
584 let exploded = explode(&src).unwrap();
585 let plan = plan_split(&exploded, 60);
586
587 assert!(
588 plan.parts.len() <= 6,
589 "satellites must consolidate, got {} parts",
590 plan.parts.len()
591 );
592 for part in &plan.parts {
593 assert!(part.loc <= 30, "part over the balanced share: {}", part.loc);
594 }
595 }
596}