rust-split 0.2.2

A parser-based god-file splitter for Rust source.
Documentation
use std::collections::BTreeSet;

use super::*;

/// The identifiers a set of moved items references — the basis for copying only
/// the imports they use. An item that does not re-parse flips `keep_all`, so the
/// caller falls back to copying every import rather than dropping a needed one.
#[derive(Default)]
pub(super) struct Refs {
    names: std::collections::BTreeSet<String>,
    keep_all: bool,
}

impl Refs {
    pub(super) fn add(&mut self, chunk_text: &str) {
        match crate::referenced_idents(chunk_text) {
            Some(names) => self.names.extend(names),
            None => self.keep_all = true,
        }
    }

    /// Add every identifier a multi-item source fragment (an extracted module
    /// body) references.
    pub(super) fn add_source(&mut self, src: &str) {
        match crate::referenced_idents_in_source(src) {
            Some(names) => self.names.extend(names),
            None => self.keep_all = true,
        }
    }

    /// True when nothing was added — no import can be needed.
    pub(super) fn is_empty(&self) -> bool {
        self.names.is_empty() && !self.keep_all
    }

    /// Whether an import providing `names` should be copied in. A name-less
    /// import (`None`: a `*` glob or `as _`) is always kept, since its usage
    /// can't be detected by name.
    fn keeps(&self, provided: &Option<std::collections::BTreeSet<String>>) -> bool {
        match provided {
            None => true,
            Some(names) => self.keep_all || names.iter().any(|n| self.names.contains(n)),
        }
    }
}

/// How a copied `use` path must be re-anchored, given where the copy lands
/// relative to the module the import was written in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Rebase {
    /// Same module — the paths already mean what they meant (the root file, and
    /// any body extracted to the module level it already had).
    Keep,
    /// One module level below a nested module: `super::` needs one more
    /// `super::`, and `self::` (the old module) becomes `super::`.
    Nested,
    /// One module level below a crate root: `self::` becomes `crate::`. A
    /// crate root has no `super::`, so nothing else can move.
    CrateRoot,
}

/// Re-anchor a copied `use` chunk for a file one module level below the one it
/// was written in. Only the leading segment moves: a `super::`/`self::` anchor
/// is re-based, and a path that starts at an item of the original module
/// (`model::*` beside `mod model;`, one of `local`) gains `super::` or
/// `crate::`, so it no longer resolves only through the part's sibling glob.
/// `crate::`, `::`-rooted and external paths are untouched, and a re-export
/// (`pub use …`) is never rewritten. Span-driven on the re-parsed chunk, so
/// leading comments, attributes and formatting survive byte-exactly.
pub(super) fn rebase_import(chunk_text: &str, rebase: Rebase, local: &BTreeSet<String>) -> String {
    if matches!(rebase, Rebase::Keep) {
        return chunk_text.to_owned();
    }
    let Ok(syn::Item::Use(item)) = syn::parse_str::<syn::Item>(chunk_text) else {
        return chunk_text.to_owned();
    };
    // A re-export is the module's public surface and stays at the root verbatim;
    // a `::`-rooted path is already absolute.
    if !is_inherited(&item.vis) || item.leading_colon.is_some() {
        return chunk_text.to_owned();
    }
    let mut edits = Vec::new();
    collect_rebase_edits(&item.tree, rebase, &mut edits);
    collect_local_anchor_edits(&item.tree, rebase, local, &mut edits);
    apply_replacements(chunk_text, &edits)
}

/// Collect an insertion of the explicit anchor before each leading segment of
/// a `use` tree that names an item of the original module. A top-level group
/// anchors each of its items separately.
fn collect_local_anchor_edits(
    tree: &syn::UseTree,
    rebase: Rebase,
    local: &BTreeSet<String>,
    edits: &mut Vec<(std::ops::Range<usize>, &'static str)>,
) {
    let anchor = match rebase {
        Rebase::Keep => return,
        Rebase::Nested => "super::",
        Rebase::CrateRoot => "crate::",
    };
    let ident = match tree {
        syn::UseTree::Path(path) => &path.ident,
        syn::UseTree::Rename(rename) => &rename.ident,
        syn::UseTree::Group(group) => {
            for item in &group.items {
                collect_local_anchor_edits(item, rebase, local, edits);
            }
            return;
        }
        _ => return,
    };
    if local.contains(&ident.to_string()) {
        let at = ident.span().byte_range().start;
        edits.push((at..at, anchor));
    }
}

/// Names of the items a relative import path in this module can start from:
/// its modules (`mod model;`, `mod model { … }`) and enums (`use Kind::*;`).
/// Other first segments — crates, names brought in by other imports — are left
/// as written.
pub(super) fn local_path_roots(exploded: &Exploded) -> BTreeSet<String> {
    exploded
        .manifest
        .rows
        .iter()
        .filter(|row| matches!(row.kind.as_str(), "mod" | "enum"))
        .map(|row| row.name.clone())
        .collect()
}

/// Collect the byte range and replacement text for each leading `super`/`self`
/// segment of a `use` tree. A top-level group (`use {super::a, self::b};`)
/// anchors each of its items separately, so it recurses; a group *after* a path
/// segment does not (`use super::{self, a};` is anchored by the `super`).
pub(super) fn collect_rebase_edits(
    tree: &syn::UseTree,
    rebase: Rebase,
    edits: &mut Vec<(std::ops::Range<usize>, &'static str)>,
) {
    match tree {
        syn::UseTree::Path(path) => collect_anchor_edit(&path.ident, rebase, edits),
        syn::UseTree::Rename(rename) => collect_anchor_edit(&rename.ident, rebase, edits),
        syn::UseTree::Group(group) => {
            for item in &group.items {
                collect_rebase_edits(item, rebase, edits);
            }
        }
        _ => {}
    }
}

pub(super) fn collect_anchor_edit(
    ident: &syn::Ident,
    rebase: Rebase,
    edits: &mut Vec<(std::ops::Range<usize>, &'static str)>,
) {
    let replacement = match (ident.to_string().as_str(), rebase) {
        ("super", Rebase::Nested) => "super::super",
        ("self", Rebase::Nested) => "super",
        ("self", Rebase::CrateRoot) => "crate",
        _ => return,
    };
    edits.push((ident.span().byte_range(), replacement));
}

/// Replace the given (non-overlapping, possibly empty) byte ranges in `text`.
/// An empty range is an insertion point.
pub(super) fn apply_replacements(text: &str, edits: &[(std::ops::Range<usize>, &str)]) -> String {
    let mut edits: Vec<(std::ops::Range<usize>, &str)> = edits.to_vec();
    edits.sort_by_key(|(range, _)| range.start);
    let mut out = String::with_capacity(text.len() + edits.len() * 8);
    let mut prev = 0;
    for (range, replacement) in &edits {
        if range.start < prev {
            continue;
        }
        out.push_str(&text[prev..range.start]);
        out.push_str(replacement);
        prev = range.end;
    }
    out.push_str(&text[prev..]);
    out
}

/// Concatenate, in source order, the import chunks referenced by `refs` (plus
/// name-less imports like `*` globs and `as _`, always kept), each re-anchored
/// for where it lands. This is the aggressive policy: an import whose every name
/// is unreferenced is dropped — including an extension trait used only via its
/// methods, whose `use` the compiler will then ask to be re-added.
pub(super) fn select_imports(
    import_chunks: &[usize],
    chunks: &[crate::Chunk],
    refs: &Refs,
    rebase: Rebase,
    local: &BTreeSet<String>,
) -> String {
    let mut out = String::new();
    for &i in import_chunks {
        let text = chunks[i].text.as_str();
        if refs.keeps(&import_provided_names(text)) {
            out.push_str(&rebase_import(text, rebase, local));
        }
    }
    out
}

/// The names a `use`/`extern crate` chunk introduces, or `None` when it exposes
/// nothing referenceable by name — a `*` glob or an `as _` import — which is
/// then kept in every file rather than dropped.
pub(super) fn import_provided_names(
    chunk_text: &str,
) -> Option<std::collections::BTreeSet<String>> {
    match syn::parse_str::<syn::Item>(chunk_text).ok()? {
        syn::Item::Use(u) => {
            let mut names = std::collections::BTreeSet::new();
            let mut name_less = false;
            collect_use_names(&u.tree, &mut names, &mut name_less);
            if name_less { None } else { Some(names) }
        }
        syn::Item::ExternCrate(c) => {
            let name = c
                .rename
                .map(|(_, id)| id.to_string())
                .unwrap_or_else(|| c.ident.to_string());
            Some(std::collections::BTreeSet::from([name]))
        }
        // An unparseable header chunk has no detectable name -> keep it.
        _ => None,
    }
}

/// Collect the bound names a `use` tree introduces. `name_less` is set for a
/// `*` glob or an `as _` rename, which bind no referenceable name.
fn collect_use_names(
    tree: &syn::UseTree,
    names: &mut std::collections::BTreeSet<String>,
    name_less: &mut bool,
) {
    match tree {
        syn::UseTree::Path(p) => collect_use_names(&p.tree, names, name_less),
        syn::UseTree::Name(n) => {
            names.insert(n.ident.to_string());
        }
        syn::UseTree::Rename(r) => {
            if r.rename == "_" {
                *name_less = true;
            } else {
                names.insert(r.rename.to_string());
            }
        }
        syn::UseTree::Glob(_) => *name_less = true,
        syn::UseTree::Group(g) => {
            for item in &g.items {
                collect_use_names(item, names, name_less);
            }
        }
    }
}