Skip to main content

rust_split/
plan.rs

1//! Tier 2 step 1 — the cohesion clusterer.
2//!
3//! This is **not** a bin-packer. The objective is to put **related items
4//! together and unrelated items apart**. The number of output files is a
5//! *consequence* of how related the code is, never something to minimise: if a
6//! file genuinely contains ten unrelated 50-line items, ten 50-line files is the
7//! correct answer, and merging any of them to "save a file" or "fill a bin"
8//! makes the split worse. `max_loc` is only a **ceiling**, never a packing
9//! target: a transitively related group bigger than **half** the ceiling is
10//! partitioned into roughly equal cohesive parts (strongest reference edges
11//! bond first, so the cuts fall on the weakest edges), leaning toward *more*
12//! files rather than files that graze the ceiling.
13//!
14//! Relation: an item references a sibling (the manifest's `adjacency_hint`).
15//! Clustering is greedy agglomerative on those reference edges, heaviest edge
16//! first, capped at the component's balanced share; leftover clusters of the
17//! same component are then consolidated in source order up to the same share.
18//! Items with no edges stay alone. Imports/preamble are pulled out as a shared
19//! header (the reassembler copies into each module only the imports it
20//! references); an item that is itself `>= max_loc` is reported oversized.
21//!
22//! Three chunk classes never enter clustering:
23//! - visibility-qualified `use`/`extern crate` (`pub use ...`) are **re-exports**
24//!   — the file's API surface — and stay at the root verbatim (`root_items`);
25//! - content-less `mod name;` declarations bind files relative to the root's
26//!   directory and stay at the root verbatim (`root_items`);
27//! - body-carrying `mod name { ... }` items are already module boundaries and
28//!   are extracted whole to their own file (`mods`), the wrapper's attributes
29//!   (e.g. `#[cfg(test)]`) and visibility traveling to the root declaration.
30
31use crate::Exploded;
32use std::collections::{BTreeMap, HashMap};
33
34/// The result of planning a split.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SplitPlan {
37    /// Chunk indices forming the shared header (preamble + plain imports).
38    pub header: Vec<usize>,
39    /// Chunk indices kept at the root verbatim, in source order: re-exports
40    /// (`pub use ...`, `pub(crate) use ...`, visibility-qualified
41    /// `extern crate`) and content-less `mod name;` declarations. Dropping or
42    /// demoting a `pub use` would silently narrow the crate's public API.
43    pub root_items: Vec<usize>,
44    /// Chunk indices of body-carrying `mod name { ... }` items, in source
45    /// order. Each is extracted whole to its own file regardless of size —
46    /// never packed into a cluster, so its `#[cfg(test)]`-style gate and
47    /// visibility can travel to the root declaration.
48    pub mods: Vec<usize>,
49    /// Cohesive destination modules, in source order.
50    pub parts: Vec<Part>,
51    /// Items that cannot be placed under the ceiling on their own.
52    pub oversized: Vec<Oversized>,
53}
54
55/// One cohesive module: a cluster of related items.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Part {
58    /// A content-derived module name (the cluster's dominant item).
59    pub name: String,
60    /// Chunk indices packed here, in source order.
61    pub chunk_indices: Vec<usize>,
62    /// Total LOC; always `< max_loc`.
63    pub loc: usize,
64}
65
66/// An item whose own LOC is at or above the ceiling.
67#[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    /// True for a `mod` (recoverable by a nested split); false for a leaf.
74    /// Body-carrying mods are now planned via `mods` (any size), so an
75    /// oversized entry is a leaf and this is false unless the mod chunk failed
76    /// to re-parse.
77    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
94/// Cluster `exploded`'s items into cohesive modules, each `< max_loc`.
95pub 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                // Chunk did not re-parse: fall back to clustering it intact.
117                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    // An unparseable mod chunk that is itself over the ceiling still needs
143    // reporting rather than forcing.
144    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
174/// Whether a `use`/`extern crate` chunk is a **re-export** (visibility-
175/// qualified: `pub`, `pub(crate)`, `pub(super)`, `pub(in ...)`). Re-exports are
176/// part of the file's API surface and must stay at the root verbatim; a plain
177/// private import is a header chunk copied into the files that reference it.
178fn 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        // Unparseable or unexpected: keeping it in the copied header is the
183        // conservative choice (nothing is dropped from the root's own uses).
184        _ => false,
185    }
186}
187
188/// `Some(true)` for a body-carrying `mod name { ... }`, `Some(false)` for a
189/// content-less `mod name;` declaration, `None` when the chunk does not
190/// re-parse as a mod.
191fn 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
198/// Greedy agglomerative clustering on reference edges.
199///
200/// The ceiling is not a packing target: each connected component (the set of
201/// transitively related items) gets a **balanced share** — a component whose
202/// total fits within half the ceiling becomes one part; a bigger one is
203/// partitioned into `ceil(total / (max_loc / 2))` roughly equal parts. Merging
204/// is heaviest-edge-first up to that share, so the strongest relations bond
205/// first and the cuts fall on the weakest edges; same-component leftovers are
206/// then consolidated in source order up to the same share. Unrelated items
207/// (separate components) are never merged.
208fn cluster(items: &[Item], max_loc: usize) -> Vec<Part> {
209    let n = items.len();
210
211    // name -> item index (first occurrence; duplicate top-level names are rare)
212    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    // weighted undirected reference edges
218    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    // heaviest edge first; ties by source position for determinism
230    edges.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
231
232    // Connected components over all edges, then each component's balanced
233    // per-part share.
234    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    // Consolidate leftovers: clusters of the same component that the capped
270    // edge pass left apart (e.g. the satellites of an already-full hub) are
271    // still transitively related, so pack them in source order up to the same
272    // share rather than emitting a file per satellite.
273    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    // collect members per root
302    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]]; // path halving
331        x = parent[x];
332    }
333    x
334}
335
336/// Name a cluster after its largest nameable item (skipping `impl`/`use`).
337fn 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        // each (name, calls) -> a fn that calls the named siblings
403        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        // ten items that reference nothing -> ten clusters (NOT fewer)
417        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        // budget is huge: they would all fit in one file, but they are unrelated
431        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        // group A: a1->a2->a3 ; group B: b1->b2 ; loner c
438        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        // a chain a->b->c->d->e, each ~4 LOC, ceiling 10 -> cannot be one module
456        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    // ----- razel regression: re-exports, mod decls, and body mods never cluster
499
500    #[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    // ----- razel regression: the ceiling is not a packing target ---------------
538
539    #[test]
540    fn a_big_connected_component_is_partitioned_not_packed_to_the_ceiling() {
541        // A chain of 12 related fns, ~11 LOC each (~132 total), budget 100.
542        // Packing-to-the-ceiling would emit one ~99-LOC file plus a remainder;
543        // the balanced share is ceil(132 / ceil(132/50)) = 44 -> three parts.
544        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        // hub + 20 satellites that each call it: once the hub's cluster reaches
577        // the balanced share, the leftover satellites are still transitively
578        // related and must consolidate, not become 16 tiny files.
579        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}