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}