rust-split 0.2.2

A parser-based god-file splitter for Rust source.
Documentation
//! Module names for generated parts.
//!
//! A part headed by a section banner (`// ---- Linking ----`) is named after
//! the banner's title. Otherwise the planner names it after its dominant item.
//! Either way the name is sanitized into a module identifier and kept clear of
//! crate names and keywords.

/// Characters a banner uses for rules and for the decoration around its title.
const DECORATION: [char; 8] = ['-', '=', '*', '#', '~', '+', '_', '/'];

/// Crates a local module of the same name would shadow inside the split
/// module: beside a `mod core;`, every `core::…` path there fails to resolve.
const CRATE_NAMES: [&str; 5] = ["alloc", "core", "proc_macro", "std", "test"];

/// Strict and reserved keywords of the 2015–2024 editions, lowercase (names
/// are sanitized to lowercase). `mod type;` does not parse.
const KEYWORDS: [&str; 51] = [
    "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate",
    "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl",
    "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
    "return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof",
    "unsafe", "unsized", "use", "virtual", "where", "while", "yield",
];

/// Most words of a banner title kept in a module name.
const TITLE_WORDS: usize = 3;

/// A module identifier for `raw`: sanitized, then suffixed with `_` when it
/// would shadow a crate or is a keyword.
pub(crate) fn module_name(raw: &str) -> String {
    let name = sanitize(raw);
    if is_reserved(&name) {
        format!("{name}_")
    } else {
        name
    }
}

/// Whether `name` must not be used as a generated module name.
pub(crate) fn is_reserved(name: &str) -> bool {
    CRATE_NAMES.contains(&name) || KEYWORDS.contains(&name)
}

/// The title of the first section banner among an item's leading comments:
/// a decorated line (`// ---- Linking ----`, `// === Dump ===`,
/// `// --- Collection`) or a title line under a rule (`// ------` then
/// `// Linking`). Text after a `:` or `(` and words past the third are
/// dropped. `None` for ordinary prose comments.
pub(crate) fn banner_title(leading: &str) -> Option<String> {
    let mut after_rule = false;
    for line in leading.lines() {
        let Some(text) = line.trim().strip_prefix("//") else {
            after_rule = false;
            continue;
        };
        let text = text.trim();
        let core = text.trim_matches(|c: char| DECORATION.contains(&c) || c.is_whitespace());
        if core.is_empty() {
            after_rule = text.chars().filter(|c| DECORATION.contains(c)).count() >= 3;
            continue;
        }
        let before = decoration_run(text.chars());
        let after = decoration_run(text.chars().rev());
        let decorated = before >= 3 || after >= 3 || (before >= 2 && after >= 2);
        if decorated || after_rule {
            let title = core
                .split([':', '('])
                .next()
                .unwrap_or_default()
                .split_whitespace()
                .take(TITLE_WORDS)
                .collect::<Vec<_>>()
                .join(" ");
            if title.chars().any(char::is_alphanumeric) {
                return Some(title);
            }
        }
        after_rule = false;
    }
    None
}

/// Number of decoration characters at the start of `chars`, ignoring spaces.
fn decoration_run(chars: impl Iterator<Item = char>) -> usize {
    chars
        .take_while(|c| DECORATION.contains(c) || c.is_whitespace())
        .filter(|c| DECORATION.contains(c))
        .count()
}

/// Lowercase ASCII alphanumerics joined by single `_`, never starting with a
/// digit and never empty.
pub(crate) fn sanitize(name: &str) -> String {
    let mut out: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect();
    while out.contains("__") {
        out = out.replace("__", "_");
    }
    let out = out.trim_matches('_').to_owned();
    if out.is_empty() || out.chars().next().unwrap().is_ascii_digit() {
        format!("m_{out}")
    } else {
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decorated_and_boxed_banners_yield_their_titles() {
        for (leading, title) in [
            ("// ---- Linking ----\n\n", "Linking"),
            ("// ==== Syntax helpers ====\n", "Syntax helpers"),
            ("    // --- Collection\n", "Collection"),
            ("// -- Dump --\n", "Dump"),
            (
                "// ------------\n// Parity checks\n// ------------\n",
                "Parity checks",
            ),
            (
                "// ----- razel regression: re-exports, mod decls, and body mods\n",
                "razel regression",
            ),
            (
                "// ---- Load the very large input files ----\n",
                "Load the very",
            ),
            ("// ---- Linking (phase 2) ----\n", "Linking"),
        ] {
            assert_eq!(banner_title(leading).as_deref(), Some(title), "{leading:?}");
        }
    }

    #[test]
    fn prose_and_bare_rules_are_not_banners() {
        for leading in [
            "",
            "// Helper for the linker.\n",
            "// - a bullet point\n",
            "// ----------------\n\n",
            "/* ---- block ---- */\n",
            "// ---- ---- ----\n",
        ] {
            assert_eq!(banner_title(leading), None, "{leading:?}");
        }
    }

    #[test]
    fn crate_names_and_keywords_get_a_suffix() {
        for (raw, name) in [
            ("Core", "core_"),
            ("std", "std_"),
            ("alloc", "alloc_"),
            ("Type", "type_"),
            ("Self", "self_"),
            ("Linking", "linking"),
            ("impl Core", "impl_core"),
            ("Syntax helpers", "syntax_helpers"),
        ] {
            assert_eq!(module_name(raw), name);
        }
    }
}