cargo-split-modules
Split large Rust source files into one-item-per-file submodules — preserving comments and the public API, with the compiler as the safety net.
Turn this:
src/parser.rs # 1500 lines: 12 structs, 30 fns, 20 impls
into this:
src/parser.rs # module index: `mod` decls + `pub use` re-exports
src/parser/
token.rs # struct Token + its impls
lexer.rs # struct Lexer + its impls
parse_expr.rs # fn parse_expr
...
…and your crate still compiles and passes its tests, unchanged.
Why it's safe
Most "move code around" tools risk breaking your build. This one is built so it cannot leave your project in a broken state:
-
The public API is preserved by construction. Each item moves into a child file, and the parent module re-exports it at its original visibility (
pub use child::Foo;,pub(crate) use …, privateuse …). Every path anywhere in your project that referencedcrate::parser::Tokenstill resolves — no call sites are rewritten. -
Children see everything via
use super::*;. All the originaluseimports stay in the parent, and child modules glob-import them along with their siblings. No import analysis, no guessing. -
Member visibility is widened safely. Moving a struct deeper would hide its private fields from sibling modules that relied on the old nesting, so private members are widened to
pub(crate)— a superset of any in-crate audience, which can never break compiling code and never changes the external API. -
Module-relative paths are rewritten with scope awareness.
super::X→super::super::Xandself::X→super::X, but only at the item's own module depth (paths inside nestedmod {}blocks are left alone). -
The compiler verifies every split. After writing files,
cargo split-modulesrunscargo check. If anything fails to compile, it rolls back the entire split, restoring the original file byte-for-byte and removing generated files. You either get a working split or no change at all.
This has been validated by splitting real crates (e.g. semver, bytes) end to end
and confirming their full test suites still pass.
What gets preserved
- Doc-comments (
///,//!) and#[derive]/attribute lines — they're part of each item's span and move with it. - Plain
//comments directly above an item, and trailing same-line comments. #[cfg(...)]attributes — replicated onto the generated re-export.- Generics,
unsafe,async, lifetimes,whereclauses — the item's source text is sliced verbatim, never reformatted away.
How items are grouped
One file per item, named after it (snake_case):
| Item | Goes to |
|---|---|
struct / enum / union / type / trait |
name.rs |
free fn |
name.rs |
const / static |
name.rs |
impl Foo / impl Trait for Foo |
co-located in foo.rs (with Foo) |
A same-named const, type alias, and struct merge into one file. impl blocks for an
external/complex self type land in impls.rs.
Things that stay in the parent: use, mod, extern crate, macro_rules!,
anonymous (const _) and _-prefixed side-effect items.
File layout
foo.rs→ a siblingfoo/directory is created andfoo.rsbecomes the module index.lib.rs/main.rs/mod.rs→ generated files go in the same directory (these already own a directory module).
Options
cargo split-modules <PATH> [OPTIONS]
PATH A .rs file to split, or a directory/crate to process recursively.
-r, --recursive Process a directory recursively (implied when PATH is a directory).
Splits every file that would yield 2+ module files.
-n, --dry-run Show what would happen without writing anything.
--no-verify Skip the cargo check + rollback safety step (faster, not advised).
--no-fmt Don't run rustfmt on generated files.
--min-groups N Minimum number of resulting module files for a split (default 2).
Known limitations (handled by safe rollback)
A file is safely skipped (rolled back, never broken) when a split would not compile —
in practice this means paths hidden inside macro token streams (some_macro!(super::X)),
or other constructs the AST can't see. You lose nothing: the file is left exactly as it
was, and the tool tells you which files it skipped.
License
Licensed under either of Apache-2.0 or MIT at your option.