rust-split 0.2.2

A parser-based god-file splitter for Rust source.
Documentation
use syn::parse::Parser;

use super::*;

/// Macros whose single argument is a path resolved relative to the *file* that
/// contains them — the one construct a byte-exact move cannot leave alone.
pub(super) const PATH_MACROS: [&str; 3] = ["include", "include_str", "include_bytes"];

/// Re-base the relative path arguments of the `include*!` macros in a file
/// written `levels` directories below the source file. Token-driven, so a path
/// spelled inside a comment or an unrelated string is never touched. An
/// absolute path is left alone; so is a non-literal argument (`concat!`,
/// `env!`, a macro, a constant), which gets one warning line on stderr naming
/// the generated file and line so the user knows where to look.
pub(super) fn rebase_includes(contents: &str, levels: usize, path: &str) -> String {
    let Ok(stream) = contents.parse::<proc_macro2::TokenStream>() else {
        return contents.to_owned();
    };
    let mut edits = Vec::new();
    let prefix = "../".repeat(levels);
    collect_include_edits(stream, &prefix, contents, path, &mut edits);
    apply_replacements(contents, &edits)
}

/// Walk a token stream for `include*!(...)` calls, recording where a `../`
/// prefix must be inserted and warning about the arguments that cannot be
/// re-based mechanically.
fn collect_include_edits<'a>(
    stream: proc_macro2::TokenStream,
    prefix: &'a str,
    contents: &str,
    path: &str,
    edits: &mut Vec<(std::ops::Range<usize>, &'a str)>,
) {
    let trees: Vec<proc_macro2::TokenTree> = stream.into_iter().collect();
    for (index, tree) in trees.iter().enumerate() {
        let proc_macro2::TokenTree::Group(group) = tree else {
            continue;
        };
        if !is_path_macro_call(&trees, index) {
            collect_include_edits(group.stream(), prefix, contents, path, edits);
            continue;
        }
        match literal_insert_point(&group.stream()) {
            Some(Some(offset)) => edits.push((offset..offset, prefix)),
            // A literal that must not move (absolute path): nothing to do.
            Some(None) => {}
            None => {
                let line = contents[..group.span().byte_range().start].lines().count();
                eprintln!(
                    "rust-split: {path}:{line}: {}! argument is not a plain string \
                     literal — its path was not re-based",
                    trees[index - 2]
                );
            }
        }
    }
}

/// Whether the group at `index` is the argument list of an `include*!` call,
/// i.e. the two preceding tokens are the macro name and its `!`.
fn is_path_macro_call(trees: &[proc_macro2::TokenTree], index: usize) -> bool {
    if index < 2 {
        return false;
    }
    let proc_macro2::TokenTree::Ident(name) = &trees[index - 2] else {
        return false;
    };
    let proc_macro2::TokenTree::Punct(bang) = &trees[index - 1] else {
        return false;
    };
    bang.as_char() == '!' && PATH_MACROS.iter().any(|m| name == m)
}

/// For an `include*!` argument list: `Some(Some(offset))` is the byte offset
/// just inside the opening quote of a re-basable relative path literal,
/// `Some(None)` a literal that must stay put (an absolute path), and `None` an
/// argument that is not a single plain string literal with an optional comma.
fn literal_insert_point(stream: &proc_macro2::TokenStream) -> Option<Option<usize>> {
    let mut trees = stream.clone().into_iter();
    let proc_macro2::TokenTree::Literal(literal) = trees.next()? else {
        return None;
    };
    if let Some(token) = trees.next()
        && (!matches!(token, proc_macro2::TokenTree::Punct(p) if p.as_char() == ',')
            || trees.next().is_some())
    {
        return None;
    }
    let text = literal.to_string();
    let value = syn::parse_str::<syn::LitStr>(&text).ok()?.value();
    // An absolute path does not move with the file; an empty one is not a path.
    let windows_drive =
        matches!(value.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic());
    if value.is_empty() || value.starts_with('/') || value.starts_with(r"\\") || windows_drive {
        return Some(None);
    }
    // Insert just inside the opening quote — `"…"`, `r"…"` and `r#"…"#` alike —
    // so the literal's own escaping is preserved byte-for-byte.
    let quote = text.find('"')?;
    Some(Some(literal.span().byte_range().start + quote + 1))
}

/// Re-anchor the `super::` / `self::` paths *inside* a moved item — expression
/// and type paths, function-local `use` items, and macro invocation paths — for
/// a body that lands one module level below its original file. Attribute and
/// arbitrary macro input tokens are opaque; arguments to common standard
/// expression macros are parsed before their paths are rewritten. Comments,
/// strings, receivers, and visibility restrictions stay byte-exact.
pub(super) fn rebase_body_paths(body: &str, rebase: Rebase) -> String {
    if matches!(rebase, Rebase::Keep) {
        return body.to_owned();
    }
    let Ok(file) = syn::parse_file(body) else {
        return body.to_owned();
    };
    let mut visitor = BodyPathEdits {
        rebase,
        edits: Vec::new(),
    };
    syn::visit::Visit::visit_file(&mut visitor, &file);
    apply_replacements(body, &visitor.edits)
}

struct BodyPathEdits {
    rebase: Rebase,
    edits: Vec<(std::ops::Range<usize>, &'static str)>,
}

impl<'ast> syn::visit::Visit<'ast> for BodyPathEdits {
    fn visit_path(&mut self, path: &'ast syn::Path) {
        if path.leading_colon.is_none() && path.segments.len() > 1 {
            collect_anchor_edit(&path.segments[0].ident, self.rebase, &mut self.edits);
        }
        syn::visit::visit_path(self, path);
    }

    fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
        if item.leading_colon.is_none() {
            collect_rebase_edits(&item.tree, self.rebase, &mut self.edits);
        }
    }

    fn visit_macro(&mut self, mac: &'ast syn::Macro) {
        syn::visit::Visit::visit_path(self, &mac.path);
        let Some(name) = mac.path.segments.last().map(|segment| &segment.ident) else {
            return;
        };
        let is_standard_path = mac.path.segments.len() == 1
            || (mac.path.segments.len() == 2
                && mac
                    .path
                    .segments
                    .first()
                    .is_some_and(|segment| segment.ident == "std" || segment.ident == "core"));
        if !is_standard_path
            || !matches!(
                name.to_string().as_str(),
                "assert"
                    | "assert_eq"
                    | "assert_ne"
                    | "debug_assert"
                    | "debug_assert_eq"
                    | "debug_assert_ne"
                    | "format"
                    | "format_args"
                    | "format_args_nl"
                    | "panic"
                    | "print"
                    | "println"
                    | "eprint"
                    | "eprintln"
                    | "write"
                    | "writeln"
            )
        {
            return;
        }
        // These standard macros accept comma-separated Rust expressions.
        // Unknown macro grammars remain opaque even when their tokens look Rust-like.
        let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
        let Ok(arguments) = parser.parse2(mac.tokens.clone()) else {
            return;
        };
        for argument in &arguments {
            syn::visit::Visit::visit_expr(self, argument);
        }
    }

    fn visit_attribute(&mut self, _: &'ast syn::Attribute) {}

    fn visit_visibility(&mut self, _: &'ast syn::Visibility) {}
}