Skip to main content

rust_split/
reassemble.rs

1//! Tier 2 step 2 — reassemble a planned split into output module files.
2//!
3//! Target: a **binary/crate-root** file (e.g. `src/main.rs`) whose items all
4//! live in one module and reference each other by bare name. Splitting them
5//! into sibling modules breaks those bare references, so the scheme is:
6//!
7//! - the crate root keeps the file preamble + imports, the source's own
8//!   re-exports (`pub use ...` — dropping or demoting one would narrow the
9//!   crate's public API) and content-less `mod name;` declarations verbatim,
10//!   declares each part module, re-exports each part with a single glob whose
11//!   visibility matches the part's widest item (`pub use` when the part has a
12//!   public item, else `pub(crate) use`, and nothing for an `impl`-only part),
13//!   keeps local types with inherent impls verbatim (their private fields stay
14//!   accessible to children), and keeps `fn main` (the entry point stays here);
15//! - each part file copies the imports it references by name (preamble stays at
16//!   the root), does `use crate::*` to see its siblings via the root re-exports,
17//!   and carries its items with `pub(crate)` visibility bumped on so the
18//!   re-exports can see them;
19//! - unconditional body-carrying modules are extracted whole, with attributes
20//!   and visibility on their declarations; large bodies are split recursively;
21//! - conditional modules keep their braced boundaries inline, and oversized
22//!   leaves stay intact in their original module. An unmet budget never
23//!   justifies dropping a declaration.
24//!
25//! Residual import/visibility that this mechanical scheme misses is left for the
26//! compiler to enumerate (the documented O(n) finish). Unmet LOC budgets are
27//! reported alongside the preserved output.
28
29use crate::{Exploded, SplitPlan, plan_split};
30
31mod body_paths;
32mod imports;
33mod inline_mod;
34mod visibility;
35
36use body_paths::*;
37use imports::*;
38use inline_mod::*;
39use visibility::*;
40
41/// Per-part overhead beyond the imports: the `use crate::*;` sibling glob plus
42/// blank-line separators.
43const PART_OVERHEAD: usize = 3;
44
45/// Plan and reassemble a binary crate-root split targeting files `< max_loc`.
46/// Indivisible items and conditional modules stay in their original scope;
47/// consult `still_oversized` and `max_loc()` for unmet budget constraints.
48/// The item budget reserves the full header LOC and the per-part overhead; since a part now copies only the imports it references (a subset of
49/// the header), this reservation is conservative — a packed part stays safely
50/// under budget.
51pub fn split_bin(exploded: &Exploded, max_loc: usize, root_stem: &str) -> SplitOutput {
52    let header_loc: usize = exploded
53        .manifest
54        .rows
55        .iter()
56        .filter(|r| matches!(r.kind.as_str(), "use" | "preamble" | "extern_crate"))
57        .map(|r| r.loc)
58        .sum();
59    let item_budget = max_loc.saturating_sub(header_loc + PART_OVERHEAD).max(1);
60    let plan = plan_split(exploded, item_budget);
61    reassemble_bin(exploded, &plan, root_stem, max_loc)
62}
63
64/// A file the split would write.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct OutputFile {
67    /// Path relative to the source file's directory (e.g. `main.rs`, `p00.rs`).
68    pub path: String,
69    pub contents: String,
70    pub loc: usize,
71}
72
73/// The reassembled output plus the parts of the plan that could not be met.
74#[derive(Debug, Clone)]
75pub struct SplitOutput {
76    pub files: Vec<OutputFile>,
77    /// Retained oversized items and output files at or above the LOC budget.
78    pub still_oversized: Vec<String>,
79}
80
81impl SplitOutput {
82    /// The largest output file's LOC (the budget is met iff this is `< max_loc`
83    /// and `still_oversized` is empty).
84    pub fn max_loc(&self) -> usize {
85        self.files.iter().map(|f| f.loc).max().unwrap_or(0)
86    }
87}
88
89fn loc(text: &str) -> usize {
90    if text.is_empty() {
91        0
92    } else {
93        text.lines().count().max(1)
94    }
95}
96
97/// How the split file sits in the module tree — fixes the sibling path prefix
98/// and where sub-module files go. Re-export visibility is per part in both
99/// topologies: `pub use` when the part has a public item (preserving the
100/// crate's public API), else no wider than the part's items need.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Topology {
103    /// A binary or library crate root (`main.rs`, `lib.rs`): siblings reached
104    /// via `crate::`, moved private items raised to `pub(crate)` and
105    /// re-exported `pub(crate) use`, `fn main` (if any) kept at the root.
106    Bin,
107    /// A nested library module (`foo/mod.rs`, or a plain `foo.rs` file module):
108    /// sub-modules reached via `super::`, moved private items raised only to
109    /// `pub(super)`, and each module re-exported with a single glob — `pub use`
110    /// to preserve a public API, `pub(crate) use` for crate-visible items, else
111    /// a private `use`. A plain `foo.rs` file module resolves `mod bar;` to
112    /// `foo/bar.rs`, so its sub-modules are written into a `foo/` subdir.
113    Mod,
114}
115
116impl Topology {
117    /// The visibility a moved private item is raised to so the original module
118    /// and the sibling parts still see it. Parts are children of that module,
119    /// so `pub(super)` is enough; at a crate root it equals `pub(crate)`, which
120    /// the crate-root output has always used.
121    fn sibling_vis(self) -> &'static str {
122        match self {
123            Topology::Bin => "pub(crate) ",
124            Topology::Mod => NESTED_SIBLING_VIS,
125        }
126    }
127}
128
129/// Sibling visibility below any module that is not a crate root: generated
130/// parts of a `--module` split and the groups of a nested split.
131const NESTED_SIBLING_VIS: &str = "pub(super) ";
132
133/// Plan and reassemble a library module targeting files `< max_loc`;
134/// sub-modules see each other via `super::*`. Unmet budgets are reported
135/// without discarding declarations. Handles both a directory-owning
136/// `foo/mod.rs` (sub-modules become siblings) and a plain `foo.rs` file module
137/// (sub-modules go in a `foo/` subdir). `root_stem` is the source file's stem.
138pub fn split_mod(exploded: &Exploded, max_loc: usize, root_stem: &str) -> SplitOutput {
139    let header_loc: usize = exploded
140        .manifest
141        .rows
142        .iter()
143        .filter(|r| matches!(r.kind.as_str(), "use" | "preamble" | "extern_crate"))
144        .map(|r| r.loc)
145        .sum();
146    let item_budget = max_loc.saturating_sub(header_loc + PART_OVERHEAD).max(1);
147    let plan = plan_split(exploded, item_budget);
148    reassemble(exploded, &plan, root_stem, Topology::Mod, max_loc)
149}
150
151/// Reassemble a binary crate-root split (back-compat wrapper).
152pub fn reassemble_bin(
153    exploded: &Exploded,
154    plan: &SplitPlan,
155    root_stem: &str,
156    max_loc: usize,
157) -> SplitOutput {
158    reassemble(exploded, plan, root_stem, Topology::Bin, max_loc)
159}
160
161/// Reassemble a split for the given module topology. `root_stem` is the source
162/// file's stem (`"main"` for `main.rs`, `"mod"` for `foo/mod.rs`). `max_loc` is
163/// the ceiling, needed to recursively split an oversized test module.
164fn reassemble(
165    exploded: &Exploded,
166    plan: &SplitPlan,
167    root_stem: &str,
168    topology: Topology,
169    max_loc: usize,
170) -> SplitOutput {
171    let sibling = match topology {
172        Topology::Bin => "crate",
173        Topology::Mod => "super",
174    };
175    // A part is a module *below* the file it came from, whichever topology, so
176    // the header imports it copies have to be re-anchored one level up.
177    let part_rebase = match topology {
178        Topology::Bin => Rebase::CrateRoot,
179        Topology::Mod => Rebase::Nested,
180    };
181    let chunk_text = |i: usize| exploded.chunks[i].text.as_str();
182    let row = |i: usize| {
183        exploded
184            .manifest
185            .rows
186            .iter()
187            .find(|r| r.chunk_index == i)
188            .unwrap()
189    };
190
191    // Where sub-module files go. A directory-owning module file
192    // (`main.rs`/`lib.rs`/`foo/mod.rs`) keeps them as siblings; a plain `foo.rs`
193    // file module resolves `mod bar;` to `foo/bar.rs`, so its parts go in `foo/`.
194    let sub_prefix = match topology {
195        Topology::Bin => String::new(),
196        Topology::Mod if matches!(root_stem, "mod" | "lib" | "main") => String::new(),
197        Topology::Mod => format!("{root_stem}/"),
198    };
199
200    // The crate/module preamble (inner `//!` docs / `#![...]` attrs) stays at
201    // the root only — a crate-root `#![feature]` is a hard error in a sub-module
202    // file, and a crate-root `#![allow]` already covers descendants. The import
203    // chunks (`use`/`extern crate`) are copied into each file, but only the ones
204    // that file references by name (see `select_imports`). Re-exports
205    // (`pub use ...`) are not imports — the planner routes them to
206    // `plan.root_items`, kept at the root verbatim.
207    let preamble: String = plan
208        .header
209        .iter()
210        .filter(|&&i| row(i).kind == "preamble")
211        .map(|&i| chunk_text(i))
212        .collect();
213    let import_chunks: Vec<usize> = plan
214        .header
215        .iter()
216        .copied()
217        .filter(|&i| row(i).kind != "preamble")
218        .collect();
219
220    // Source chunks kept verbatim: re-exports, file-mod declarations, and local
221    // types whose inherent impls move into children. The parent's private fields
222    // remain visible to those children without changing the type's visibility.
223    let root_kept: String = plan.root_items.iter().map(|&i| chunk_text(i)).collect();
224
225    // Module names fixed by the source — extracted body-mods and `mod name;`
226    // declarations and retained types keep their own names — plus the root's
227    // stem and every name the imports and re-exports bind (`mod fmt;` beside
228    // `use std::fmt;` is a conflict); a generated part that would collide is
229    // renamed with a `_` suffix.
230    let mut reserved: std::collections::BTreeSet<String> =
231        plan.mods.iter().map(|&i| row(i).name.clone()).collect();
232    reserved.insert(root_stem.to_owned());
233    for &i in plan
234        .root_items
235        .iter()
236        .chain(&import_chunks)
237        .chain(plan.oversized.iter().map(|o| &o.chunk_index))
238    {
239        if row(i).kind == "mod" {
240            reserved.insert(row(i).name.clone());
241        } else if let Some(names) = import_provided_names(chunk_text(i)) {
242            reserved.extend(names);
243        } else if matches!(
244            row(i).kind.as_str(),
245            "struct" | "enum" | "union" | "type" | "trait" | "trait_alias"
246        ) {
247            reserved.insert(row(i).name.clone());
248        }
249    }
250
251    // Items of the original module that a relative import path can start
252    // from; copies of such imports in a part are anchored explicitly.
253    let local_names = local_path_roots(exploded);
254
255    let mut files = Vec::new();
256    let mut mod_decls = String::new();
257    let mut reexports = String::new();
258    let mut still_oversized = Vec::new();
259    // Names referenced by retained types and other root items, `fn main`, and
260    // extracted module bodies whose `use super::*` reaches the root's imports.
261    let mut root_refs = Refs::default();
262    for &i in &plan.root_items {
263        root_refs.add(chunk_text(i));
264    }
265
266    // Part modules, named after their cluster's dominant item.
267    for part in &plan.parts {
268        let mut module = part.name.clone();
269        while !reserved.insert(module.clone()) {
270            module.push('_');
271        }
272        let mut body = String::new();
273        let mut root_main = None;
274        // Widest visibility any moved item contributes to a glob re-export.
275        let mut export_vis = ExportVis::None;
276        // Names this part references, so only the imports it uses are copied in.
277        let mut refs = Refs::default();
278        for &i in &part.chunk_indices {
279            let r = row(i);
280            // `fn main` must stay at the crate root, not move into a submodule.
281            if r.kind == "fn" && r.name == "main" {
282                root_main = Some(i);
283                continue;
284            }
285            export_vis = export_vis.max(item_export_vis(chunk_text(i)));
286            refs.add(chunk_text(i));
287            body.push_str(&rebase_body_paths(
288                &bump_visibility(chunk_text(i), topology.sibling_vis()),
289                part_rebase,
290            ));
291        }
292        // If the part was only `fn main`, it produced no module file.
293        if body.trim().is_empty() {
294            if let Some(i) = root_main {
295                // stash main to emit at root via a sentinel part with no module
296                files.push(OutputFile {
297                    path: String::from("__root_main__"),
298                    contents: chunk_text(i).to_owned(),
299                    loc: 0,
300                });
301            }
302            continue;
303        }
304        if let Some(i) = root_main {
305            files.push(OutputFile {
306                path: String::from("__root_main__"),
307                contents: chunk_text(i).to_owned(),
308                loc: 0,
309            });
310        }
311        let part_imports = select_imports(
312            &import_chunks,
313            &exploded.chunks,
314            &refs,
315            part_rebase,
316            &local_names,
317        );
318        let contents = part_file(&part_imports, sibling, &body);
319        mod_decls.push_str(&format!("mod {module};\n"));
320        // One glob re-export per module, visibility chosen by the module's widest
321        // item: `pub use` when it has a public item (a `pub use *` caps each item
322        // at its own visibility, so it covers internal items in the same line);
323        // `pub(crate) use` for crate-visible items; a private `use` for items
324        // that were private to a nested module; and nothing when the module
325        // exposes no nameable item (e.g. a bare `impl` — a glob re-export would
326        // only warn "doesn't reexport anything"). This keeps the crate's public
327        // surface (`pub use`) reachable and avoids the redundant double
328        // re-export.
329        if let Some(line) = reexport_line(export_vis, &module, topology) {
330            reexports.push_str(&line);
331        }
332        let l = loc(&contents);
333        files.push(OutputFile {
334            path: format!("{sub_prefix}{module}.rs"),
335            contents,
336            loc: l,
337        });
338    }
339
340    // Unconditional modules are extracted; conditional modules keep their
341    // original braced scope, including mutually exclusive same-named bodies.
342    for &mod_index in &plan.mods {
343        let chunk = chunk_text(mod_index);
344        let name = row(mod_index).name.as_str();
345        let Some((decl, inner)) = extract_mod(chunk) else {
346            // Conditional or unparseable modules remain intact at the root.
347            root_refs.add(chunk);
348            mod_decls.push_str(chunk);
349            continue;
350        };
351        mod_decls.push_str(&decl);
352        // The body's `use super::*;` reaches the root's imports, so the root
353        // must keep the imports the body names.
354        root_refs.add_source(&inner);
355        let inner_loc = loc(&inner);
356        if inner_loc < max_loc {
357            files.push(OutputFile {
358                path: format!("{sub_prefix}{name}.rs"),
359                contents: inner,
360                loc: inner_loc,
361            });
362        } else {
363            let (nested, nested_still) = split_nested_mod(name, &inner, max_loc);
364            files.extend(nested.into_iter().map(|mut f| {
365                f.path = format!("{sub_prefix}{}", f.path);
366                f
367            }));
368            still_oversized.extend(nested_still);
369        }
370    }
371
372    // Keep oversized leaves at their original scope, without visibility or
373    // path changes. Retain imports they reference just as for `main`.
374    let mut oversized_kept = String::new();
375    for over in &plan.oversized {
376        let chunk = chunk_text(over.chunk_index);
377        oversized_kept.push_str(chunk);
378        root_refs.add(chunk);
379        still_oversized.push(format!(
380            "{} ({} LOC {}, manual extraction)",
381            over.name, over.loc, over.kind
382        ));
383    }
384
385    // Pull the stashed `fn main` (if any) and build the root file.
386    let mut root_main = String::new();
387    files.retain(|f| {
388        if f.path == "__root_main__" {
389            root_main = f.contents.clone();
390            false
391        } else {
392            true
393        }
394    });
395
396    // The root keeps the preamble, then only the imports its own retained code
397    // references — retained types and re-exports, `fn main` (if any), plus
398    // extracted module bodies whose `use super::*;` resolves against the root.
399    // A root with no references needs no imports.
400    if !root_main.trim().is_empty() {
401        root_refs.add(&root_main);
402    }
403    let root_imports = if root_refs.is_empty() {
404        String::new()
405    } else {
406        // The root file *is* the original module: its own imports still mean
407        // what they meant.
408        select_imports(
409            &import_chunks,
410            &exploded.chunks,
411            &root_refs,
412            Rebase::Keep,
413            &local_names,
414        )
415    };
416
417    let mut root = String::new();
418    root.push_str(&preamble);
419    root.push_str(&root_imports);
420    root.push_str(&root_kept);
421    root.push('\n');
422    root.push_str(&mod_decls);
423    root.push('\n');
424    root.push_str(&reexports);
425    root.push_str(&oversized_kept);
426    if !root_main.is_empty() {
427        root.push('\n');
428        root.push_str(&root_main);
429    }
430    let root_loc = loc(&root);
431    files.insert(
432        0,
433        OutputFile {
434            path: format!("{root_stem}.rs"),
435            contents: root,
436            loc: root_loc,
437        },
438    );
439
440    // `include!`/`include_str!`/`include_bytes!` resolve their argument against
441    // the directory of the file that contains them, so a body written into a
442    // subdirectory needs one `../` per level it moved down. The root file did
443    // not move (no `/` in its path), and neither did a part that stayed a
444    // sibling. Inserting `../` adds no lines, so `loc` is unaffected.
445    for file in &mut files {
446        let levels = file.path.matches('/').count();
447        if levels == 0 || !file.contents.contains("include") {
448            continue;
449        }
450        file.contents = rebase_includes(&file.contents, levels, &file.path);
451    }
452
453    for file in &files {
454        if file.loc >= max_loc {
455            still_oversized.push(format!(
456                "{} ({} LOC, contents preserved; exceeds output budget)",
457                file.path, file.loc
458            ));
459        }
460    }
461
462    SplitOutput {
463        files,
464        still_oversized,
465    }
466}
467
468/// Assemble a part file: its selected imports, the sibling glob so it can reach
469/// the rest of the module via the root re-exports, then the moved items.
470fn part_file(imports: &str, sibling: &str, body: &str) -> String {
471    if imports.trim().is_empty() {
472        format!("use {sibling}::*;\n\n{body}")
473    } else {
474        format!("{imports}\nuse {sibling}::*;\n\n{body}")
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::{explode, plan_split};
482
483    mod body_paths;
484    mod imports;
485    mod inline_mod;
486    mod split;
487    mod visibility;
488}