Skip to main content

split_modules/
plan.rs

1//! Build a [`SplitPlan`] from a single source file: parse, classify, slice, group,
2//! and render both the child files' item sources and the rewritten parent.
3
4use 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
14/// Determine how the file owns its submodules.
15fn 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
22/// Names already taken in the target directory / parent module, which generated
23/// module stems must not collide with.
24fn reserved_names(path: &Path, out_dir: &Path, layout: Layout, file: &syn::File) -> BTreeSet<String> {
25    let mut set = BTreeSet::new();
26    // Existing `mod x;` / `mod x { .. }` declarations and names bound by top-level
27    // `use` imports (a generated `mod vec;` must not collide with `use ...::vec;`).
28    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    // Existing `.rs` files in the output directory.
38    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    // The source file's own module stem (for DirOwner, files share the directory).
49    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
57/// Collect the names bound by a `use` tree (final segments and `as` renames). Glob
58/// imports bind unknown names and are skipped.
59fn 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
77/// Make `stem` unique against `reserved`, recording the chosen name as reserved.
78fn 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
94/// Render the visibility + cfg prefix for a re-export line.
95fn 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
108/// Apply relative visibility edits to an item's source text (back-to-front so earlier
109/// offsets stay valid).
110fn 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
120/// Render one child module file's full contents.
121pub 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
142/// Build the split plan for `path` with the already-read `src`.
143pub 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    // First pass: classify + slice, accumulating moved items keyed by raw group.
163    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    // Assign final, de-duplicated stems per raw group (stable in first-seen order).
215    let mut stem_map: BTreeMap<String, String> = BTreeMap::new();
216    // Preserve first-seen order of raw groups.
217    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    // Materialise moved items with final stems.
230    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    // Group moved item indices by final stem, preserving original order within.
246    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    // Rewrite the parent: delete moved ranges, then append mod decls + re-exports.
259    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    // Delete moved spans from the original source, working back-to-front.
278    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        // Also swallow a single trailing newline left behind by the deletion.
282        let mut e2 = e;
283        if body[e2..].starts_with('\n') {
284            e2 += 1;
285        }
286        body.replace_range(s..e2, "");
287    }
288    // Collapse 3+ consecutive blank lines that deletions may leave behind.
289    let body = collapse_blank_lines(&body);
290
291    let mut out = body.trim_end().to_string();
292    out.push('\n');
293
294    // Module declarations, sorted for determinism.
295    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    // Re-exports, grouped per file in original item order.
304    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
323/// Replace runs of 3+ newlines (2+ blank lines) with a single blank line.
324fn 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
341/// Child file path for a group stem under this plan.
342pub fn child_path(plan: &SplitPlan, stem: &str) -> PathBuf {
343    plan.out_dir.join(format!("{stem}.rs"))
344}