Naming a target module by hand is easy. Knowing which of the other 40 unlisted items belong in it is not — until now.
Today we released SplitRS 0.3.3 — a release focused on making --target-modules genuinely smart about where things belong, and on reaching into nested mod blocks that previously stopped the splitter cold.
SplitRS is the AST-based Rust refactoring tool that splits oversized source files into clean, compilable modules: it parses with syn, clusters methods and types, infers visibility, and writes the imports and mod.rs facades for you. No C, no external formatter shelling out to system tools — it links prettyplease in-process and compiles to a single static binary.
Why 0.3.3 is a meaningful step
Naming target modules by hand used to mean one of two outcomes for everything you didn’t explicitly list:
- It fell back to the generic heuristic buckets, scattering related-but-unlisted items away from the module they actually belong with.
- Or you had to enumerate every single item yourself, including the ones only tangentially related to your named groups.
0.3.3 ends that trade-off with real domain-mapping:
- Seeded assignment by reference affinity. With
assign_unlisted = "seeded"(or per-rulepull_dependencies = true), items not matched by any rule get pulled into the named module with the strongest reference affinity — iterated to a fixpoint so second- and third-order dependencies land correctly. Zero-affinity items still fall back to the classic heuristic buckets, so nothing is ever silently dropped. - It tells you why, not just what. Dry-run now reports which rule (or seed edge) pulled each item into a named module — no more guessing why something ended up where it did.
- It catches typos before they become bugs. An exact
itemspattern that names nothing in the file is now a hard validation error, complete with near-miss suggestions. - The schema grew up.
[[target_modules]]rules now supportparent(route inside a module descended by--split-nested-mods),pull_dependencies, a customdocheader, and per-rulemax_lines(a module that overflows spills into<name>_2.rs,<name>_3.rs, …). - Glob matching got richer. Infix (
*foo*) and multi-segment (a*b*c) patterns join the existing exact/prefix/suffix forms.
And separately: --split-nested-mods finally lets the splitter descend recursively through inline mod x { ... } bodies that are themselves over budget — through the same analyze → group → generate pipeline, at any depth, with super:: paths depth-adjusted per level.
Technical Deep Dive: domain routing and recursive descent
src/domain_router.rs— the seeded fixpoint. For each named-module rule withpull_dependenciesenabled, the router treats already-assigned items as seeds and repeatedly scans unlisted items for references into a seeded module, pulling in the strongest-affinity match each pass, until no more items move.- Unknown-name validation (
validate_target_modules()). Before any routing happens, every exactitemsentry is checked against the file’s actual item names; a name that matches nothing fails fast with a suggestion, and duplicate module names within the sameparentscope, emptyitemslists, and misplaced catch-all*rules are rejected too. src/nested_mod_splitter.rs— recursive descent. Over-budget inlinemodbodies are fed back through the same file-analysis pipeline that handles top-level files, emitting<output>/x/mod.rsrecursively; the source map (src/source_map.rs) keeps absolute byte spans correct at every descent level, so verbatim emission — including now individual extracted impl methods — keeps working no matter how deep you go.- Facade control.
--facade <glob|named|none>(or[output] facadein.splitrs.toml) chooses how each generatedmod.rsre-exports its children: apub use x::*;glob, explicit named re-exports, or plainmoddeclarations with nothing re-exported.
Getting Started
Install the latest SplitRS:
cargo install splitrs
Route unlisted items by reference affinity:
# .splitrs.toml
assign_unlisted = "seeded"
[[target_modules]]
name = "geometry"
items = ["Point", "Vector"]
pull_dependencies = true
splitrs --input src/large_file.rs --output src/large_file/ --dry-run
# reports which rule (or seed edge) pulled each unlisted item into `geometry`
Descend into oversized nested mod blocks:
splitrs \
--input src/large_file.rs \
--output src/large_file/ \
--split-nested-mods \
--max-mod-depth 3 \
--facade named
What’s New in 0.3.3
- Domain-mapping for
--target-modules: seeded assignment by reference affinity (assign_unlisted = "seeded",pull_dependencies = true), iterated to a fixpoint, with heuristic fallback for zero-affinity items. - Unknown-name validation: exact
itemspatterns that name nothing in the file are now hard errors, with near-miss suggestions. - Dry-run attribution: reports which rule or seed edge pulled each item into a named module.
- Extended
[[target_modules]]schema:parent,pull_dependencies,doc, and per-rulemax_lines(with<name>_2.rs,<name>_3.rs, … overflow). validate_target_modules(): rejects duplicate module names within aparentscope, emptyitemsrules, and misplaced catch-all*rules.- Infix and multi-segment glob patterns (
*foo*,a*b*c) for[[target_modules]]item matching. --split-nested-mods/--max-mod-depth(src/nested_mod_splitter.rs): recursive descent into over-budget inlinemodblocks, withsuper::path depth-adjustment at every level.--facade <glob|named|none>: controls the re-export style of generatedmod.rsfacades.- Verbatim method extraction:
SourceMapnow covers individual extracted impl methods, not just whole-item moves. - New integration test suites:
acceptance_e2e_tests,domain_mapping_tests,nested_mod_tests. - Test suite grew to 565 tests with
--all-features(494 with default features), up from 450 in 0.3.2.
Tips
- Turn on
pull_dependenciesper rule, not globally, if you want mixed behavior. You can seed some named modules and leave others purely heuristic in the same config. - Read the dry-run attribution before committing. It tells you exactly which rule or seed edge claimed each unlisted item — the fastest way to catch a routing rule that’s too greedy.
- Fix unknown-name errors immediately — they’re catching real typos. The near-miss suggestion is usually the item name you meant.
- Use
max_lineson[[target_modules]]rules the same way you’d use it globally — a named module that grows past budget spills cleanly into numbered continuation files instead of becoming the next 2,000-line problem. - Reach for
--split-nested-modson files with big inline test or logicmodblocks rather than manually pulling them out first — the splitter now handles the descent for you, at any depth. - Pick
--facade nonewhen you want to control re-exports by hand — useful when a generated module’s public surface needs to be curated rather than auto-derived.
This is the foundation
SplitRS remains the development workhorse behind the COOLJAPAN policy of keeping every file under 2,000 lines — it’s used across the ecosystem’s Rust codebases (OxiZ, NumRS2, SciRS2, and dozens of oxi* crates) whenever a module outgrows its budget. Domain-mapping in 0.3.3 means large, tangled files can now be routed by actual reference structure, not just naming heuristics.
Repository: https://github.com/cool-japan/splitrs
Star the repo if a refactoring tool that understands why an item belongs in a module — not just what you told it — is what your codebase needs. Pure Rust refactoring is here — fast, safe, and now domain-aware.
— KitaSan at COOLJAPAN OÜ July 6, 2026