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