1use std::collections::{BTreeMap, BTreeSet};
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use syn::spanned::Spanned;
9
10use crate::classify::{classify, ItemClass};
11use crate::model::{GroupFile, Layout, MovedItem, ReExport, SplitPlan, VisEdit};
12use crate::util::{extend_trailing_comment, leading_comment_start};
13
14fn layout_of(path: &Path) -> Layout {
16 match path.file_name().and_then(|s| s.to_str()) {
17 Some("mod.rs") | Some("lib.rs") | Some("main.rs") => Layout::DirOwner,
18 _ => Layout::Adjacent,
19 }
20}
21
22fn reserved_names(path: &Path, out_dir: &Path, layout: Layout, file: &syn::File) -> BTreeSet<String> {
25 let mut set = BTreeSet::new();
26 for item in &file.items {
29 match item {
30 syn::Item::Mod(m) => {
31 set.insert(m.ident.to_string());
32 }
33 syn::Item::Use(u) => collect_use_names(&u.tree, &mut set),
34 _ => {}
35 }
36 }
37 if let Ok(rd) = std::fs::read_dir(out_dir) {
39 for entry in rd.flatten() {
40 let p = entry.path();
41 if p.extension().and_then(|e| e.to_str()) == Some("rs") {
42 if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
43 set.insert(stem.to_string());
44 }
45 }
46 }
47 }
48 if layout == Layout::DirOwner {
50 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
51 set.insert(stem.to_string());
52 }
53 }
54 set
55}
56
57fn collect_use_names(tree: &syn::UseTree, set: &mut BTreeSet<String>) {
60 match tree {
61 syn::UseTree::Path(p) => collect_use_names(&p.tree, set),
62 syn::UseTree::Name(n) => {
63 set.insert(n.ident.to_string());
64 }
65 syn::UseTree::Rename(r) => {
66 set.insert(r.rename.to_string());
67 }
68 syn::UseTree::Group(g) => {
69 for item in &g.items {
70 collect_use_names(item, set);
71 }
72 }
73 syn::UseTree::Glob(_) => {}
74 }
75}
76
77fn unique_stem(stem: &str, reserved: &mut BTreeSet<String>) -> String {
79 if !reserved.contains(stem) {
80 reserved.insert(stem.to_string());
81 return stem.to_string();
82 }
83 let mut n = 2;
84 loop {
85 let candidate = format!("{stem}_{n}");
86 if !reserved.contains(&candidate) {
87 reserved.insert(candidate.clone());
88 return candidate;
89 }
90 n += 1;
91 }
92}
93
94fn reexport_line(stem: &str, re: &ReExport) -> String {
96 let mut prefix = String::new();
97 for cfg in &re.cfg_attrs {
98 prefix.push_str(cfg);
99 prefix.push(' ');
100 }
101 if re.vis.is_empty() {
102 format!("{prefix}use {stem}::{};", re.name)
103 } else {
104 format!("{prefix}{} use {stem}::{};", re.vis, re.name)
105 }
106}
107
108fn apply_vis_edits(text: &str, edits: &[VisEdit]) -> String {
111 let mut sorted: Vec<&VisEdit> = edits.iter().collect();
112 sorted.sort_by(|a, b| b.rel_start.cmp(&a.rel_start));
113 let mut out = text.to_string();
114 for e in sorted {
115 out.replace_range(e.rel_start..e.rel_end, &e.text);
116 }
117 out
118}
119
120pub fn render_child(plan: &SplitPlan, file: &GroupFile) -> String {
122 let mut out = String::new();
123 out.push_str("#[allow(unused_imports)]\nuse super::*;\n");
124 for &idx in &file.item_indices {
125 let item = &plan.moved[idx];
126 out.push('\n');
127 if !item.leading_comment.is_empty() {
128 out.push_str(item.leading_comment.trim_end_matches(['\n', ' ', '\t']));
129 out.push('\n');
130 }
131 let body = if item.vis_edits.is_empty() {
132 item.text.clone()
133 } else {
134 apply_vis_edits(&item.text, &item.vis_edits)
135 };
136 out.push_str(body.trim_end());
137 out.push('\n');
138 }
139 out
140}
141
142pub fn build_plan(path: &Path, src: &str) -> Result<SplitPlan> {
144 let file = syn::parse_file(src)
145 .with_context(|| format!("failed to parse {} as Rust", path.display()))?;
146
147 let layout = layout_of(path);
148 let parent_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
149 let out_dir = match layout {
150 Layout::Adjacent => {
151 let stem = path
152 .file_stem()
153 .and_then(|s| s.to_str())
154 .context("source file has no stem")?;
155 parent_dir.join(stem)
156 }
157 Layout::DirOwner => parent_dir.clone(),
158 };
159
160 let mut reserved = reserved_names(path, &out_dir, layout, &file);
161
162 struct Raw {
164 raw_group: String,
165 leading_comment: String,
166 text: String,
167 vis_edits: Vec<VisEdit>,
168 reexport: Option<ReExport>,
169 order: usize,
170 delete: (usize, usize),
171 }
172 let mut raws: Vec<Raw> = Vec::new();
173 let mut consumed_end = 0usize;
174
175 for (order, item) in file.items.iter().enumerate() {
176 let span = item.span().byte_range();
177 let (start, end) = (span.start, span.end);
178 let lead_start = leading_comment_start(src, consumed_end, start);
179 let end_ext = extend_trailing_comment(src, end);
180 consumed_end = end_ext;
181
182 match classify(item) {
183 ItemClass::Keep => {}
184 ItemClass::Move(info) => {
185 let leading_comment = src[lead_start..start].to_string();
186 let text = src[start..end_ext].to_string();
187 let vis_edits = info
188 .vis_edits_abs
189 .into_iter()
190 .map(|e| VisEdit {
191 rel_start: e.start.saturating_sub(start),
192 rel_end: e.end.saturating_sub(start),
193 text: e.text,
194 })
195 .collect();
196 let reexport = info.reexport.map(|(vis, name, cfg_attrs)| ReExport {
197 vis,
198 name,
199 cfg_attrs,
200 });
201 raws.push(Raw {
202 raw_group: info.group,
203 leading_comment,
204 text,
205 vis_edits,
206 reexport,
207 order,
208 delete: (lead_start, end_ext),
209 });
210 }
211 }
212 }
213
214 let mut stem_map: BTreeMap<String, String> = BTreeMap::new();
216 let mut seen_order: Vec<String> = Vec::new();
218 for r in &raws {
219 if !stem_map.contains_key(&r.raw_group) {
220 seen_order.push(r.raw_group.clone());
221 stem_map.insert(r.raw_group.clone(), String::new());
222 }
223 }
224 for raw_group in &seen_order {
225 let final_stem = unique_stem(raw_group, &mut reserved);
226 stem_map.insert(raw_group.clone(), final_stem);
227 }
228
229 let mut moved: Vec<MovedItem> = Vec::with_capacity(raws.len());
231 let mut delete_ranges: Vec<(usize, usize)> = Vec::with_capacity(raws.len());
232 for r in &raws {
233 let group = stem_map[&r.raw_group].clone();
234 delete_ranges.push(r.delete);
235 moved.push(MovedItem {
236 group,
237 leading_comment: r.leading_comment.clone(),
238 text: r.text.clone(),
239 vis_edits: r.vis_edits.clone(),
240 reexport: r.reexport.clone(),
241 order: r.order,
242 });
243 }
244
245 let mut files_map: BTreeMap<String, Vec<usize>> = BTreeMap::new();
247 for (idx, m) in moved.iter().enumerate() {
248 files_map.entry(m.group.clone()).or_default().push(idx);
249 }
250 let files: Vec<GroupFile> = files_map
251 .into_iter()
252 .map(|(stem, mut item_indices)| {
253 item_indices.sort_by_key(|&i| moved[i].order);
254 GroupFile { stem, item_indices }
255 })
256 .collect();
257
258 let parent_contents = build_parent(src, &mut delete_ranges, &files, &moved);
260
261 Ok(SplitPlan {
262 source_path: path.to_path_buf(),
263 layout,
264 out_dir,
265 parent_contents,
266 moved,
267 files,
268 })
269}
270
271fn build_parent(
272 src: &str,
273 delete_ranges: &mut [(usize, usize)],
274 files: &[GroupFile],
275 moved: &[MovedItem],
276) -> String {
277 let mut body = src.to_string();
279 delete_ranges.sort_by(|a, b| b.0.cmp(&a.0));
280 for &(s, e) in delete_ranges.iter() {
281 let mut e2 = e;
283 if body[e2..].starts_with('\n') {
284 e2 += 1;
285 }
286 body.replace_range(s..e2, "");
287 }
288 let body = collapse_blank_lines(&body);
290
291 let mut out = body.trim_end().to_string();
292 out.push('\n');
293
294 out.push('\n');
296 out.push_str("// === split-modules: generated submodules ===\n");
297 let mut stems: Vec<&str> = files.iter().map(|f| f.stem.as_str()).collect();
298 stems.sort_unstable();
299 for stem in &stems {
300 out.push_str(&format!("mod {stem};\n"));
301 }
302
303 let mut any_reexport = false;
305 let mut reexport_block = String::new();
306 for file in files {
307 for &idx in &file.item_indices {
308 if let Some(re) = &moved[idx].reexport {
309 reexport_block.push_str(&reexport_line(&file.stem, re));
310 reexport_block.push('\n');
311 any_reexport = true;
312 }
313 }
314 }
315 if any_reexport {
316 out.push('\n');
317 out.push_str(&reexport_block);
318 }
319
320 out
321}
322
323fn collapse_blank_lines(s: &str) -> String {
325 let mut out = String::with_capacity(s.len());
326 let mut newline_run = 0;
327 for ch in s.chars() {
328 if ch == '\n' {
329 newline_run += 1;
330 if newline_run <= 2 {
331 out.push(ch);
332 }
333 } else {
334 newline_run = 0;
335 out.push(ch);
336 }
337 }
338 out
339}
340
341pub fn child_path(plan: &SplitPlan, stem: &str) -> PathBuf {
343 plan.out_dir.join(format!("{stem}.rs"))
344}