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 handled separately (`mods`). Unconditional modules are extracted;
29//!   conditional modules retain their braced scopes inline.
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. Never packed into a cluster: unconditional modules are extracted
46    /// whole; conditional modules retain their original braced boundaries.
47    pub mods: Vec<usize>,
48    /// Cohesive destination modules, in source order.
49    pub parts: Vec<Part>,
50    /// Items that cannot be placed under the ceiling on their own.
51    pub oversized: Vec<Oversized>,
52}
53
54/// One cohesive module: a cluster of related items.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Part {
57    /// A content-derived module name (the cluster's dominant item).
58    pub name: String,
59    /// Chunk indices packed here, in source order.
60    pub chunk_indices: Vec<usize>,
61    /// Total LOC; always `< max_loc`.
62    pub loc: usize,
63}
64
65/// An item whose own LOC is at or above the ceiling.
66#[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    /// True for a `mod` (recoverable by a nested split); false for a leaf.
73    /// Body-carrying mods are now planned via `mods` (any size), so an
74    /// oversized entry is a leaf and this is false unless the mod chunk failed
75    /// to re-parse.
76    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
93/// Cluster `exploded`'s items into cohesive modules, each `< max_loc`.
94pub 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                // Chunk did not re-parse: fall back to clustering it intact.
116                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    // An unparseable mod chunk that is itself over the ceiling still needs
142    // reporting rather than forcing.
143    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
173/// Whether a `use`/`extern crate` chunk is a **re-export** (visibility-
174/// qualified: `pub`, `pub(crate)`, `pub(super)`, `pub(in ...)`). Re-exports are
175/// part of the file's API surface and must stay at the root verbatim; a plain
176/// private import is a header chunk copied into the files that reference it.
177fn 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        // Unparseable or unexpected: keeping it in the copied header is the
182        // conservative choice (nothing is dropped from the root's own uses).
183        _ => false,
184    }
185}
186
187/// `Some(true)` for a body-carrying `mod name { ... }`, `Some(false)` for a
188/// content-less `mod name;` declaration, `None` when the chunk does not
189/// re-parse as a mod.
190fn 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
197/// Greedy agglomerative clustering on reference edges.
198///
199/// The ceiling is not a packing target: each connected component (the set of
200/// transitively related items) gets a **balanced share** — a component whose
201/// total fits within half the ceiling becomes one part; a bigger one is
202/// partitioned into `ceil(total / (max_loc / 2))` roughly equal parts. Merging
203/// is heaviest-edge-first up to that share, so the strongest relations bond
204/// first and the cuts fall on the weakest edges; same-component leftovers are
205/// then consolidated in source order up to the same share. Unrelated items
206/// (separate components) are never merged.
207fn cluster(items: &[Item], max_loc: usize) -> Vec<Part> {
208    let n = items.len();
209
210    // name -> item index (first occurrence; duplicate top-level names are rare)
211    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    // weighted undirected reference edges
217    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    // heaviest edge first; ties by source position for determinism
229    edges.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
230
231    // Connected components over all edges, then each component's balanced
232    // per-part share.
233    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    // Consolidate leftovers: clusters of the same component that the capped
269    // edge pass left apart (e.g. the satellites of an already-full hub) are
270    // still transitively related, so pack them in source order up to the same
271    // share rather than emitting a file per satellite.
272    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    // collect members per root
301    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]]; // path halving
330        x = parent[x];
331    }
332    x
333}
334
335/// Name a cluster after its largest nameable item (skipping `impl`/`use`).
336fn 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        // each (name, calls) -> a fn that calls the named siblings
402        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        // ten items that reference nothing -> ten clusters (NOT fewer)
416        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        // budget is huge: they would all fit in one file, but they are unrelated
430        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        // group A: a1->a2->a3 ; group B: b1->b2 ; loner c
437        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        // a chain a->b->c->d->e, each ~4 LOC, ceiling 10 -> cannot be one module
455        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    // ----- razel regression: re-exports, mod decls, and body mods never cluster
498
499    #[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    // ----- razel regression: the ceiling is not a packing target ---------------
537
538    #[test]
539    fn a_big_connected_component_is_partitioned_not_packed_to_the_ceiling() {
540        // A chain of 12 related fns, ~11 LOC each (~132 total), budget 100.
541        // Packing-to-the-ceiling would emit one ~99-LOC file plus a remainder;
542        // the balanced share is ceil(132 / ceil(132/50)) = 44 -> three parts.
543        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        // hub + 20 satellites that each call it: once the hub's cluster reaches
576        // the balanced share, the leftover satellites are still transitively
577        // related and must consolidate, not become 16 tiny files.
578        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}