use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use syn::spanned::Spanned;
use crate::classify::{classify, ItemClass};
use crate::model::{GroupFile, Layout, MovedItem, ReExport, SplitPlan, VisEdit};
use crate::util::{extend_trailing_comment, leading_comment_start};
fn layout_of(path: &Path) -> Layout {
match path.file_name().and_then(|s| s.to_str()) {
Some("mod.rs") | Some("lib.rs") | Some("main.rs") => Layout::DirOwner,
_ => Layout::Adjacent,
}
}
fn reserved_names(path: &Path, out_dir: &Path, layout: Layout, file: &syn::File) -> BTreeSet<String> {
let mut set = BTreeSet::new();
for item in &file.items {
match item {
syn::Item::Mod(m) => {
set.insert(m.ident.to_string());
}
syn::Item::Use(u) => collect_use_names(&u.tree, &mut set),
_ => {}
}
}
if let Ok(rd) = std::fs::read_dir(out_dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("rs") {
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
set.insert(stem.to_string());
}
}
}
}
if layout == Layout::DirOwner {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
set.insert(stem.to_string());
}
}
set
}
fn collect_use_names(tree: &syn::UseTree, set: &mut BTreeSet<String>) {
match tree {
syn::UseTree::Path(p) => collect_use_names(&p.tree, set),
syn::UseTree::Name(n) => {
set.insert(n.ident.to_string());
}
syn::UseTree::Rename(r) => {
set.insert(r.rename.to_string());
}
syn::UseTree::Group(g) => {
for item in &g.items {
collect_use_names(item, set);
}
}
syn::UseTree::Glob(_) => {}
}
}
fn unique_stem(stem: &str, reserved: &mut BTreeSet<String>) -> String {
if !reserved.contains(stem) {
reserved.insert(stem.to_string());
return stem.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{stem}_{n}");
if !reserved.contains(&candidate) {
reserved.insert(candidate.clone());
return candidate;
}
n += 1;
}
}
fn reexport_line(stem: &str, re: &ReExport) -> String {
let mut prefix = String::new();
for cfg in &re.cfg_attrs {
prefix.push_str(cfg);
prefix.push(' ');
}
if re.vis.is_empty() {
format!("{prefix}use {stem}::{};", re.name)
} else {
format!("{prefix}{} use {stem}::{};", re.vis, re.name)
}
}
fn apply_vis_edits(text: &str, edits: &[VisEdit]) -> String {
let mut sorted: Vec<&VisEdit> = edits.iter().collect();
sorted.sort_by(|a, b| b.rel_start.cmp(&a.rel_start));
let mut out = text.to_string();
for e in sorted {
out.replace_range(e.rel_start..e.rel_end, &e.text);
}
out
}
pub fn render_child(plan: &SplitPlan, file: &GroupFile) -> String {
let mut out = String::new();
out.push_str("#[allow(unused_imports)]\nuse super::*;\n");
for &idx in &file.item_indices {
let item = &plan.moved[idx];
out.push('\n');
if !item.leading_comment.is_empty() {
out.push_str(item.leading_comment.trim_end_matches(['\n', ' ', '\t']));
out.push('\n');
}
let body = if item.vis_edits.is_empty() {
item.text.clone()
} else {
apply_vis_edits(&item.text, &item.vis_edits)
};
out.push_str(body.trim_end());
out.push('\n');
}
out
}
pub fn build_plan(path: &Path, src: &str) -> Result<SplitPlan> {
let file = syn::parse_file(src)
.with_context(|| format!("failed to parse {} as Rust", path.display()))?;
let layout = layout_of(path);
let parent_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
let out_dir = match layout {
Layout::Adjacent => {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.context("source file has no stem")?;
parent_dir.join(stem)
}
Layout::DirOwner => parent_dir.clone(),
};
let mut reserved = reserved_names(path, &out_dir, layout, &file);
struct Raw {
raw_group: String,
leading_comment: String,
text: String,
vis_edits: Vec<VisEdit>,
reexport: Option<ReExport>,
order: usize,
delete: (usize, usize),
}
let mut raws: Vec<Raw> = Vec::new();
let mut consumed_end = 0usize;
for (order, item) in file.items.iter().enumerate() {
let span = item.span().byte_range();
let (start, end) = (span.start, span.end);
let lead_start = leading_comment_start(src, consumed_end, start);
let end_ext = extend_trailing_comment(src, end);
consumed_end = end_ext;
match classify(item) {
ItemClass::Keep => {}
ItemClass::Move(info) => {
let leading_comment = src[lead_start..start].to_string();
let text = src[start..end_ext].to_string();
let vis_edits = info
.vis_edits_abs
.into_iter()
.map(|e| VisEdit {
rel_start: e.start.saturating_sub(start),
rel_end: e.end.saturating_sub(start),
text: e.text,
})
.collect();
let reexport = info.reexport.map(|(vis, name, cfg_attrs)| ReExport {
vis,
name,
cfg_attrs,
});
raws.push(Raw {
raw_group: info.group,
leading_comment,
text,
vis_edits,
reexport,
order,
delete: (lead_start, end_ext),
});
}
}
}
let mut stem_map: BTreeMap<String, String> = BTreeMap::new();
let mut seen_order: Vec<String> = Vec::new();
for r in &raws {
if !stem_map.contains_key(&r.raw_group) {
seen_order.push(r.raw_group.clone());
stem_map.insert(r.raw_group.clone(), String::new());
}
}
for raw_group in &seen_order {
let final_stem = unique_stem(raw_group, &mut reserved);
stem_map.insert(raw_group.clone(), final_stem);
}
let mut moved: Vec<MovedItem> = Vec::with_capacity(raws.len());
let mut delete_ranges: Vec<(usize, usize)> = Vec::with_capacity(raws.len());
for r in &raws {
let group = stem_map[&r.raw_group].clone();
delete_ranges.push(r.delete);
moved.push(MovedItem {
group,
leading_comment: r.leading_comment.clone(),
text: r.text.clone(),
vis_edits: r.vis_edits.clone(),
reexport: r.reexport.clone(),
order: r.order,
});
}
let mut files_map: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (idx, m) in moved.iter().enumerate() {
files_map.entry(m.group.clone()).or_default().push(idx);
}
let files: Vec<GroupFile> = files_map
.into_iter()
.map(|(stem, mut item_indices)| {
item_indices.sort_by_key(|&i| moved[i].order);
GroupFile { stem, item_indices }
})
.collect();
let parent_contents = build_parent(src, &mut delete_ranges, &files, &moved);
Ok(SplitPlan {
source_path: path.to_path_buf(),
layout,
out_dir,
parent_contents,
moved,
files,
})
}
fn build_parent(
src: &str,
delete_ranges: &mut [(usize, usize)],
files: &[GroupFile],
moved: &[MovedItem],
) -> String {
let mut body = src.to_string();
delete_ranges.sort_by(|a, b| b.0.cmp(&a.0));
for &(s, e) in delete_ranges.iter() {
let mut e2 = e;
if body[e2..].starts_with('\n') {
e2 += 1;
}
body.replace_range(s..e2, "");
}
let body = collapse_blank_lines(&body);
let mut out = body.trim_end().to_string();
out.push('\n');
out.push('\n');
out.push_str("// === split-modules: generated submodules ===\n");
let mut stems: Vec<&str> = files.iter().map(|f| f.stem.as_str()).collect();
stems.sort_unstable();
for stem in &stems {
out.push_str(&format!("mod {stem};\n"));
}
let mut any_reexport = false;
let mut reexport_block = String::new();
for file in files {
for &idx in &file.item_indices {
if let Some(re) = &moved[idx].reexport {
reexport_block.push_str(&reexport_line(&file.stem, re));
reexport_block.push('\n');
any_reexport = true;
}
}
}
if any_reexport {
out.push('\n');
out.push_str(&reexport_block);
}
out
}
fn collapse_blank_lines(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut newline_run = 0;
for ch in s.chars() {
if ch == '\n' {
newline_run += 1;
if newline_run <= 2 {
out.push(ch);
}
} else {
newline_run = 0;
out.push(ch);
}
}
out
}
pub fn child_path(plan: &SplitPlan, stem: &str) -> PathBuf {
plan.out_dir.join(format!("{stem}.rs"))
}