rust-split 0.2.2

A parser-based god-file splitter for Rust source.
Documentation

rust-split

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.

Suggested Split Policy

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:

  • Around 1,000 LOC, review the file's cohesion and decide whether it should be split. This is a soft review trigger, not an automatic split at line 1,001.
  • When a split is warranted, target responsibility owners below 500 LOC.
  • 500 LOC is a ceiling, not a packing target. Prefer cohesive smaller files over filling files toward 499 lines.
  • Split earlier when a smaller file becomes a dumping ground for unrelated concepts.
  • Prefer planning ownership boundaries before implementation so large-file churn does not become a separate refactor.

See SplitPolicy.md for the full suggested rule.

For agents

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:

  1. explode to a temp dir, then diff the concatenated chunks against the original and confirm it is byte-identical before trusting the split.
  2. Read manifest.toml and adjust grouping there — don't re-cluster by hand.
  3. split --out <tempdir> (never in place first), then copy in deliberately.
  4. Let the compiler enumerate the fallout: run the crate's build and fix the exact use / pub(crate) errors it reports. Don't predict visibility by reading.
  5. Proven when the crate's existing tests stay green on a pure-move diff. Do not run a formatter — it destroys the pure-move diff; formatting is a separate pass.

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.

Workflow

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(super) / pub(crate) visibility.

Install

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

Smoke Test Installers

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.

Commands

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.toml

Split 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.

How It Splits

explode parses the file with syn and records one manifest row per top-level item. Each row includes:

  • item name and kind
  • byte range and line span
  • LOC
  • sibling identifier references as adjacency_hint

Comments between two items are divided at the first run of blank lines after the earlier item: comment lines directly below an item stay with it, and everything after the blank lines leads the next item. A section banner set off by blank lines (// ---- Linking ----) therefore travels with the item below it. Doc comments and attributes always belong to their item.

split treats plain use/extern crate imports and the file preamble as a shared header. These chunk classes never enter clustering:

  • Local types with inherent impls (struct, enum, union) stay verbatim in the parent module. Child implementation modules can access the parent's private fields, so the type and its fields need no visibility changes. An unqualified or self::Type impl identifies a local owner, including generic and oversized impls; trait-only impls and paths through other modules do not.
  • Owner-local impls with explicit pub(self) or pub(in self) members stay in the parent, preserving their restrictions and comments verbatim.
  • Local macros needed by retained types or owner-local impls stay with their syntactic macro consumers and helper macros in the parent, in source order. This preserves lexical resolution across macro redefinitions without expanding macros. Matching identifiers conservatively may retain additional declarations or inline modules; the resulting parent can remain above budget.
  • Re-exports (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.
  • Inline modules (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;.

Section banners start responsibility boundaries lasting until the next banner; even repeated titles start distinct sections. Reference edges never join items across these boundaries, including indirectly through a shared helper. Within each section (or throughout an unbannered file), remaining items are clustered by sibling-reference adjacency. Keeping shared types in the parent removes their reference edges, so independent impl blocks no longer cluster just because they implement the same type. Unrelated items within a section still stay separate; a banner does not force them into one file.

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.

A generated module is named after the first section banner heading one of its items (// ---- Syntax helpers ---- gives syntax_helpers.rs), else after its largest named item. A name that would shadow a crate inside the module (core, std, alloc, proc_macro, test), a keyword, or a name the root already binds gets a _ suffix (core_.rs).

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:

  • binary roots use use crate::*
  • nested module splits use use super::*

A copied import that starts at a module or enum declared in the split file (use model::*; beside mod model;, use Kind::*; beside enum Kind) is anchored explicitly — use super::model::*; in a nested split, use crate::model::*; below a crate root — rather than resolving only through the sibling glob.

Moved private items, fields of moved structs, and inherent impl members are raised just far enough for the sibling modules and the root to see them: pub(super) in a --module split and in the groups of a nested split, pub(crate) at a crate root, where the two are the same scope. Items that already carry a visibility keep it.

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 for crate-visible items, and for items that were private either pub(crate) use (crate root) or a private use (nested module, whose children still see it). A module that exposes no nameable item (such as a bare impl) gets no re-export. A re-export is never wider than the items behind it, which would draw rustc's "glob import doesn't reexport anything" warning.

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.

Verification

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.

Limitations

  • adjacency_hint is syntactic. It records sibling identifier references, not a full semantic call graph, so shadowing and macro expansion can affect grouping.
  • Responsibility grouping uses explicit banners and local inherent-type ownership, not semantic analysis. Without those hints, reference adjacency still decides grouping. Aliases, qualified paths other than self::Type, macro-generated impls, and trait-only impls do not establish parent ownership. Many retained types can leave the parent over budget; this is reported rather than moving shared ownership to an arbitrary child. Review the layout and use the skill's explode-then-assemble workflow when manual boundaries fit better.
  • A moved pub(super) item keeps its restriction, which now names the generated module's parent — the original module — rather than the original module's parent.
  • Registration macros or framework-specific blocks may need manual treatment. Attribute tokens and unknown macro input are left verbatim. Paths in parsed expression arguments to common standard assertion, formatting, and output macros are re-based.
  • Large leaf items cannot be split internally; they are reported as still oversized.
  • A #[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.