rust-split is a parser-based helper for breaking up large Rust source files.
It is meant for the mechanical part of a split: find top-level items, preserve
their attached comments and attributes, group related items, and write a module
layout that stays under a requested LOC ceiling.
It does not decide when a file should be split. Use your project's own rule for that decision, then run this tool when you want the mechanical carve-out.
rust-split ships with a suggested policy for teams and agents that do not
already have one. Treat it as operating guidance, not as behavior enforced by
the CLI:
See SplitPolicy.md for the full suggested rule.
Reach for this instead of hand-editing. Moving items by cut-and-paste is O(n²) in
edits and silently orphans doc-comments and #[attrs]; explode + split is
O(n) and verifiable. (This tool decides how, never when — your repo's rules
own that.)
Loop:
explode to a temp dir, then diff the concatenated chunks against the
original and confirm it is byte-identical before trusting the split.manifest.toml and adjust grouping there — don't re-cluster by hand.split --out <tempdir> (never in place first), then copy in deliberately.use / pub(crate) errors it reports. Don't predict visibility by
reading.split extracts an inline #[cfg(test)] mod tests { … } to its own file
itself, the gate traveling to the root declaration. When you hand-finish from
explode chunks instead, that is on you: keep the wrapper's attributes with the
moved module and declare it as a top-level mod in the destination file (a
file-module's submodule resolves to a subdir).
One thing the tool won't fix for you: registration blocks
(#[starlark_module], framework macros) need re-wrapping into N blocks and
re-registering by hand.
The tool works in two passes. The first (explode) is lossless: concatenating
the generated chunk files in order must reproduce the original file exactly. The
second (split) rewrites the module graph, so it intentionally adds module
declarations, re-exports, imports, and some pub(crate) visibility.
Install with Cargo:
cargo install rust-split
Install the latest release on macOS or Linux:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.sh | sh
Install the latest release on Windows PowerShell:
powershell -ExecutionPolicy Bypass -c "irm https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.ps1 | iex"
The latest URLs point at the newest non-prerelease GitHub Release. If you want
a pinned install, replace latest with a concrete tag such as v0.1.1:
https://github.com/owebeeone/rust-split/releases/download/v0.1.1/rust-split-installer.sh
Users who already have Rust can install from source:
cargo install --git https://github.com/owebeeone/rust-split
Test the Unix installer without modifying PATH:
tmp="$(mktemp -d)"
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.sh \
-o "${tmp}/rust-split-installer.sh"
RUST_SPLIT_UNMANAGED_INSTALL="${tmp}/bin" \
RUST_SPLIT_NO_MODIFY_PATH=1 \
sh "${tmp}/rust-split-installer.sh"
"${tmp}/bin/rust-split" --version
"${tmp}/bin/rust-split" --help
Test the Windows installer without modifying PATH:
$ErrorActionPreference = "Stop"
$tmp = Join-Path $env:TEMP "rust-split-test-$([guid]::NewGuid())"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$installer = Join-Path $tmp "rust-split-installer.ps1"
Invoke-WebRequest `
"https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.ps1" `
-OutFile $installer
$env:RUST_SPLIT_UNMANAGED_INSTALL = Join-Path $tmp "bin"
$env:RUST_SPLIT_NO_MODIFY_PATH = "1"
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
& $installer
$exe = Join-Path $env:RUST_SPLIT_UNMANAGED_INSTALL "rust-split.exe"
& $exe --version
& $exe --help
Release assets are checksummed and have GitHub artifact attestations. The installers are convenience scripts; users who want stronger verification should download the release asset, verify the attestation, compare the SHA-256 checksum, and then install.
Build a local development binary:
cargo build
./target/debug/rust-split --help
Or install the current checkout onto your Cargo bin path:
cargo install --path .
rust-split --help
While developing the tool, any rust-split ... command below can also be run as
cargo run -- ....
Explode a file into chunks:
rust-split explode path/to/file.rs --out /tmp/file-chunks
This writes:
chunk-000.rs, chunk-001.rs, ...manifest.tomlSplit a binary crate root, such as src/main.rs:
rust-split split src/main.rs --max-loc 500 --out /tmp/split-main
Split a nested module file, such as src/workspace_ops/mod.rs:
rust-split split src/workspace_ops/mod.rs --module --max-loc 500 --out /tmp/split-workspace-ops
Omit --out to write in place next to the source file. For real work, prefer
using --out first, reviewing the generated layout, then copying the result
into the repo deliberately.
explode parses the file with syn and records one manifest row per top-level
item. Each row includes:
adjacency_hintsplit treats plain use/extern crate imports and the file preamble as a
shared header. Three chunk classes never enter clustering:
pub use ..., and any visibility-qualified import) are the
file's API surface and stay at the root verbatim — never dropped, never
demoted to pub(crate).mod name; declarations bind files relative to the root's directory and
stay at the root verbatim.mod name { ... }, any size) are already module
boundaries: each is extracted whole to its own file, with its attributes
(#[cfg(test)] keeps gating the declaration), doc comments, and visibility
traveling to the root's mod name;.Everything else is clustered by sibling-reference adjacency. The LOC ceiling is a hard upper bound but not a packing target: a transitively related group larger than half the ceiling is partitioned into roughly equal cohesive parts (strongest reference edges bond first, so cuts fall on the weakest edges), leaning toward more, smaller files rather than one file grazing the ceiling. Unrelated items are left separate rather than packed together just to reduce file count.
The file preamble (inner //! docs and #![...] attributes) stays at the root.
Each generated file copies only the imports it references by name; * globs and
as _ trait imports, which expose no name, are kept everywhere. Modules reach
their siblings through the generated root:
use crate::*use super::*The root re-exports each module with a single glob whose visibility matches the
module's widest item: pub use when it has a public item (preserving the crate's
public surface), pub(crate) use otherwise, and nothing for a module that
exposes no nameable item (such as a bare impl). Moved private items, struct
fields, and inherent impl members may be bumped to pub(crate) so sibling
modules can still refer to them.
A plain foo.rs file module places its sub-modules in a foo/ subdir — where
mod bar; resolves — so the layout compiles without a manual move; a foo/mod.rs
keeps its sub-modules as siblings.
Because a generated part is a module below the file it came from, the imports
copied into it are re-anchored: a use super::… gains one super::, and a
use self::… becomes use super::… (or use crate::… below a crate root).
crate::, ::-rooted and external paths are untouched, and the root file — still
the original module — keeps its header verbatim. For the same reason, a relative
include! / include_str! / include_bytes! path in a body that moved into a
subdirectory gains one ../ per level.
Run the tool's own checks with:
cargo test
cargo clippy --all-targets --all-features -- -D warnings
After applying a generated split to another crate, run that crate's normal
verification commands. rust-split handles the mechanical move, but the compiler
is still the authority for import paths, visibility, macro edge cases, and public
API preservation.
adjacency_hint is syntactic. It records sibling identifier references, not a
full semantic call graph, so shadowing and macro expansion can affect grouping.#[path = "…"] declaration in the parent changes where rustc looks for the
generated sub-modules, so a #[path]-declared module must be split with the
attribute removed (one line in the parent) or the output moved by hand.include! / include_str! / include_bytes! paths are re-based only when the
argument is a single plain string literal (with an optional trailing comma)
holding a relative path. An absolute path is left alone, and a computed
argument — concat!(env!("CARGO_MANIFEST_DIR"), …), a macro, a constant — cannot be re-based mechanically: it is left verbatim
and reported on stderr with the generated file and line to check.26 commits
Rust
99.6%
rust-split is a parser-based helper for breaking up large Rust source files.
It is meant for the mechanical part of a split: find top-level items, preserve
their attached comments and attributes, group related items, and write a module
layout that stays under a requested LOC ceiling.
It does not decide when a file should be split. Use your project's own rule for that decision, then run this tool when you want the mechanical carve-out.
rust-split ships with a suggested policy for teams and agents that do not
already have one. Treat it as operating guidance, not as behavior enforced by
the CLI:
See SplitPolicy.md for the full suggested rule.
Reach for this instead of hand-editing. Moving items by cut-and-paste is O(n²) in
edits and silently orphans doc-comments and #[attrs]; explode + split is
O(n) and verifiable. (This tool decides how, never when — your repo's rules
own that.)
Loop:
explode to a temp dir, then diff the concatenated chunks against the
original and confirm it is byte-identical before trusting the split.manifest.toml and adjust grouping there — don't re-cluster by hand.split --out <tempdir> (never in place first), then copy in deliberately.use / pub(crate) errors it reports. Don't predict visibility by
reading.split extracts an inline #[cfg(test)] mod tests { … } to its own file
itself, the gate traveling to the root declaration. When you hand-finish from
explode chunks instead, that is on you: keep the wrapper's attributes with the
moved module and declare it as a top-level mod in the destination file (a
file-module's submodule resolves to a subdir).
One thing the tool won't fix for you: registration blocks
(#[starlark_module], framework macros) need re-wrapping into N blocks and
re-registering by hand.
The tool works in two passes. The first (explode) is lossless: concatenating
the generated chunk files in order must reproduce the original file exactly. The
second (split) rewrites the module graph, so it intentionally adds module
declarations, re-exports, imports, and some pub(crate) visibility.
Install with Cargo:
cargo install rust-split
Install the latest release on macOS or Linux:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.sh | sh
Install the latest release on Windows PowerShell:
powershell -ExecutionPolicy Bypass -c "irm https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.ps1 | iex"
The latest URLs point at the newest non-prerelease GitHub Release. If you want
a pinned install, replace latest with a concrete tag such as v0.1.1:
https://github.com/owebeeone/rust-split/releases/download/v0.1.1/rust-split-installer.sh
Users who already have Rust can install from source:
cargo install --git https://github.com/owebeeone/rust-split
Test the Unix installer without modifying PATH:
tmp="$(mktemp -d)"
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.sh \
-o "${tmp}/rust-split-installer.sh"
RUST_SPLIT_UNMANAGED_INSTALL="${tmp}/bin" \
RUST_SPLIT_NO_MODIFY_PATH=1 \
sh "${tmp}/rust-split-installer.sh"
"${tmp}/bin/rust-split" --version
"${tmp}/bin/rust-split" --help
Test the Windows installer without modifying PATH:
$ErrorActionPreference = "Stop"
$tmp = Join-Path $env:TEMP "rust-split-test-$([guid]::NewGuid())"
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$installer = Join-Path $tmp "rust-split-installer.ps1"
Invoke-WebRequest `
"https://github.com/owebeeone/rust-split/releases/latest/download/rust-split-installer.ps1" `
-OutFile $installer
$env:RUST_SPLIT_UNMANAGED_INSTALL = Join-Path $tmp "bin"
$env:RUST_SPLIT_NO_MODIFY_PATH = "1"
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
& $installer
$exe = Join-Path $env:RUST_SPLIT_UNMANAGED_INSTALL "rust-split.exe"
& $exe --version
& $exe --help
Release assets are checksummed and have GitHub artifact attestations. The installers are convenience scripts; users who want stronger verification should download the release asset, verify the attestation, compare the SHA-256 checksum, and then install.
Build a local development binary:
cargo build
./target/debug/rust-split --help
Or install the current checkout onto your Cargo bin path:
cargo install --path .
rust-split --help
While developing the tool, any rust-split ... command below can also be run as
cargo run -- ....
Explode a file into chunks:
rust-split explode path/to/file.rs --out /tmp/file-chunks
This writes:
chunk-000.rs, chunk-001.rs, ...manifest.tomlSplit a binary crate root, such as src/main.rs:
rust-split split src/main.rs --max-loc 500 --out /tmp/split-main
Split a nested module file, such as src/workspace_ops/mod.rs:
rust-split split src/workspace_ops/mod.rs --module --max-loc 500 --out /tmp/split-workspace-ops
Omit --out to write in place next to the source file. For real work, prefer
using --out first, reviewing the generated layout, then copying the result
into the repo deliberately.
explode parses the file with syn and records one manifest row per top-level
item. Each row includes:
adjacency_hintsplit treats plain use/extern crate imports and the file preamble as a
shared header. Three chunk classes never enter clustering:
pub use ..., and any visibility-qualified import) are the
file's API surface and stay at the root verbatim — never dropped, never
demoted to pub(crate).mod name; declarations bind files relative to the root's directory and
stay at the root verbatim.mod name { ... }, any size) are already module
boundaries: each is extracted whole to its own file, with its attributes
(#[cfg(test)] keeps gating the declaration), doc comments, and visibility
traveling to the root's mod name;.Everything else is clustered by sibling-reference adjacency. The LOC ceiling is a hard upper bound but not a packing target: a transitively related group larger than half the ceiling is partitioned into roughly equal cohesive parts (strongest reference edges bond first, so cuts fall on the weakest edges), leaning toward more, smaller files rather than one file grazing the ceiling. Unrelated items are left separate rather than packed together just to reduce file count.
The file preamble (inner //! docs and #![...] attributes) stays at the root.
Each generated file copies only the imports it references by name; * globs and
as _ trait imports, which expose no name, are kept everywhere. Modules reach
their siblings through the generated root:
use crate::*use super::*The root re-exports each module with a single glob whose visibility matches the
module's widest item: pub use when it has a public item (preserving the crate's
public surface), pub(crate) use otherwise, and nothing for a module that
exposes no nameable item (such as a bare impl). Moved private items, struct
fields, and inherent impl members may be bumped to pub(crate) so sibling
modules can still refer to them.
A plain foo.rs file module places its sub-modules in a foo/ subdir — where
mod bar; resolves — so the layout compiles without a manual move; a foo/mod.rs
keeps its sub-modules as siblings.
Because a generated part is a module below the file it came from, the imports
copied into it are re-anchored: a use super::… gains one super::, and a
use self::… becomes use super::… (or use crate::… below a crate root).
crate::, ::-rooted and external paths are untouched, and the root file — still
the original module — keeps its header verbatim. For the same reason, a relative
include! / include_str! / include_bytes! path in a body that moved into a
subdirectory gains one ../ per level.
Run the tool's own checks with:
cargo test
cargo clippy --all-targets --all-features -- -D warnings
After applying a generated split to another crate, run that crate's normal
verification commands. rust-split handles the mechanical move, but the compiler
is still the authority for import paths, visibility, macro edge cases, and public
API preservation.
adjacency_hint is syntactic. It records sibling identifier references, not a
full semantic call graph, so shadowing and macro expansion can affect grouping.#[path = "…"] declaration in the parent changes where rustc looks for the
generated sub-modules, so a #[path]-declared module must be split with the
attribute removed (one line in the parent) or the output moved by hand.include! / include_str! / include_bytes! paths are re-based only when the
argument is a single plain string literal (with an optional trailing comma)
holding a relative path. An absolute path is left alone, and a computed
argument — concat!(env!("CARGO_MANIFEST_DIR"), …), a macro, a constant — cannot be re-based mechanically: it is left verbatim
and reported on stderr with the generated file and line to check.26 commits
Rust
99.6%