rust-split 0.2.0

A parser-based god-file splitter for Rust source.
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use rust_split::{Exploded, ManifestRow, explode};

fn assert_tiling(src: &str, exploded: &Exploded) {
    let mut cursor = 0;
    let mut joined = String::new();
    for chunk in &exploded.chunks {
        assert_eq!(chunk.byte_range.start, cursor);
        assert!(chunk.byte_range.end >= chunk.byte_range.start);
        assert_eq!(
            chunk.text,
            &src[chunk.byte_range.start..chunk.byte_range.end]
        );
        cursor = chunk.byte_range.end;
        joined.push_str(&chunk.text);
    }
    assert_eq!(cursor, src.len());
    assert_eq!(joined, src);
}

fn row<'a>(exploded: &'a Exploded, name: &str) -> &'a ManifestRow {
    exploded
        .manifest
        .rows
        .iter()
        .find(|row| row.name == name)
        .unwrap_or_else(|| panic!("missing manifest row {name:?}"))
}

fn chunk_text<'a>(exploded: &'a Exploded, row: &ManifestRow) -> &'a str {
    &exploded.chunks[row.chunk_index].text
}

fn span_text<'a>(src: &'a str, row: &ManifestRow) -> &'a str {
    &src[row.span.start..row.span.end]
}

fn fixture_paths(root: &Path) -> Vec<PathBuf> {
    let mut paths = Vec::new();
    collect_rs(root, &mut paths);
    paths.sort();
    paths
}

fn expected_real_fixture_paths() -> Vec<&'static str> {
    vec![
        "gwz-cli/src/main.rs",
        "gwz-cli/tests/local_workflows.rs",
        "gwz-cli/tests/publish_workflow.rs",
        "gwz-cli/tests/rename.rs",
        "gwz-core/protocol/corpus/rust/vectors.rs",
        "gwz-core/src/artifact/mod.rs",
        "gwz-core/src/cbor.rs",
        "gwz-core/src/git/mod.rs",
        "gwz-core/src/lib.rs",
        "gwz-core/src/model/mod.rs",
        "gwz-core/src/operation/mod.rs",
        "gwz-core/src/protocol/convert.rs",
        "gwz-core/src/protocol/generated.rs",
        "gwz-core/src/protocol/mod.rs",
        "gwz-core/src/runtime/clock.rs",
        "gwz-core/src/runtime/ids.rs",
        "gwz-core/src/runtime/mod.rs",
        "gwz-core/src/status/mod.rs",
        "gwz-core/src/workspace/mod.rs",
        "gwz-core/src/workspace_ops/mod.rs",
        "gwz-core/tests/protocol.rs",
        "gwz-core/tests/publish_workflow.rs",
        "gwz-core/tests/rename.rs",
    ]
}

fn collect_rs(path: &Path, out: &mut Vec<PathBuf>) {
    for entry in fs::read_dir(path).unwrap() {
        let entry = entry.unwrap();
        let path = entry.path();
        if path.is_dir() {
            collect_rs(&path, out);
        } else if path.extension().is_some_and(|extension| extension == "rs") {
            out.push(path);
        }
    }
}

#[test]
fn whole_gwz_fixture_corpus_tiles_losslessly() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/real");
    let paths = fixture_paths(&root);
    let actual = paths
        .iter()
        .map(|path| {
            path.strip_prefix(&root)
                .unwrap()
                .to_string_lossy()
                .replace('\\', "/")
        })
        .collect::<Vec<_>>();
    assert_eq!(actual, expected_real_fixture_paths());

    for path in paths {
        let src = fs::read_to_string(&path).unwrap();
        let exploded = explode(&src)
            .unwrap_or_else(|error| panic!("failed to explode {}: {error}", path.display()));
        assert_tiling(&src, &exploded);
        assert_eq!(
            explode(&src).unwrap(),
            exploded,
            "not deterministic for {}",
            path.display()
        );
    }
}

#[test]
fn doc_comments_and_outer_attrs_belong_to_the_item() {
    let src = "/// docs\n#[allow(dead_code)]\nfn documented() {}\n";
    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    let documented = row(&exploded, "documented");
    assert_eq!(documented.kind, "fn");
    assert_eq!(chunk_text(&exploded, documented), src);
    assert!(span_text(src, documented).starts_with("/// docs"));
}

#[test]
fn inner_attrs_are_preamble_not_item_owned() {
    let src = "//! module docs\n#![allow(dead_code)]\nfn item() {}\n";
    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    assert_eq!(exploded.chunks.len(), 2);
    assert_eq!(exploded.manifest.rows[0].kind, "preamble");
    assert!(exploded.chunks[0].text.contains("//! module docs"));

    let item = row(&exploded, "item");
    assert_eq!(chunk_text(&exploded, item), "fn item() {}\n");
    assert_eq!(span_text(src, item), "fn item() {}");
}

#[test]
fn free_comment_gap_attaches_to_following_item_after_last_blank_line() {
    let src = "fn a() {}\n\n// belongs to b\nfn b() {}\n";
    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    let a = row(&exploded, "a");
    let b = row(&exploded, "b");
    assert_eq!(chunk_text(&exploded, a), "fn a() {}\n\n");
    assert_eq!(chunk_text(&exploded, b), "// belongs to b\nfn b() {}\n");
}

#[test]
fn leading_license_without_inner_attrs_attaches_to_first_item() {
    let src = "// Copyright\n// SPDX-License-Identifier: GPL-2.0-only\nfn first() {}\n";
    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    assert_eq!(exploded.chunks.len(), 1);
    assert_eq!(chunk_text(&exploded, row(&exploded, "first")), src);
}

#[test]
fn trailing_comment_and_final_newline_attach_to_last_item() {
    let src = "fn last() {}\n// trailing\n";
    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    assert_eq!(chunk_text(&exploded, row(&exploded, "last")), src);
}

#[test]
fn macro_impl_test_mod_crlf_and_unicode_boundaries_are_stable() {
    let src = "#[cfg(test)]\r\nmod tests {\r\n    #[test]\r\n    fn it_works() {}\r\n}\r\n\r\nmacro_rules! say_hi {\r\n    () => { println!(\"hi\"); };\r\n}\r\n\r\ntrait Hello {}\r\nstruct Café;\r\nimpl Hello for Café {}\r\n";
    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    assert_eq!(row(&exploded, "tests").kind, "mod");
    assert_eq!(row(&exploded, "say_hi").kind, "macro_rules");
    assert_eq!(row(&exploded, "impl Hello for Café").kind, "impl");
    assert_eq!(
        span_text(src, row(&exploded, "impl Hello for Café")),
        "impl Hello for Café {}"
    );
}

#[test]
fn empty_comment_only_inner_attr_only_and_use_only_files_are_handled() {
    for src in ["", "// only\n", "//! only\n#![allow(dead_code)]\n"] {
        let exploded = explode(src).unwrap();
        assert_tiling(src, &exploded);
        if src.is_empty() {
            assert!(exploded.chunks.is_empty());
        } else {
            assert_eq!(exploded.chunks.len(), 1);
            assert_eq!(exploded.manifest.rows[0].kind, "preamble");
        }
    }

    let use_only = "use std::{fmt, io};\n";
    let exploded = explode(use_only).unwrap();
    assert_tiling(use_only, &exploded);
    assert_eq!(row(&exploded, "std::{ fmt, io }").kind, "use");
}

#[test]
fn manifest_names_and_kinds_cover_top_level_item_variants() {
    let src = r#"
extern crate core;
use std::fmt;
const C: usize = 1;
static S: usize = 2;
type Alias = usize;
struct Struct;
union MyUnion { a: usize }
enum Enum { A }
trait Trait { fn method(&self); }
trait AliasTrait = Trait;
fn function() {}
mod module {}
extern "C" { fn foreign(); }
impl Trait for Struct { fn method(&self) {} }
macro_rules! rules { () => {}; }
custom_macro! { tokens }
"#;

    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    let expected = [
        ("core", "extern_crate"),
        ("std::fmt", "use"),
        ("C", "const"),
        ("S", "static"),
        ("Alias", "type"),
        ("Struct", "struct"),
        ("MyUnion", "union"),
        ("Enum", "enum"),
        ("Trait", "trait"),
        ("AliasTrait", "trait_alias"),
        ("function", "fn"),
        ("module", "mod"),
        ("extern \"C\"", "foreign_mod"),
        ("impl Trait for Struct", "impl"),
        ("rules", "macro_rules"),
        ("custom_macro", "macro"),
    ];

    for (name, kind) in expected {
        let row = row(&exploded, name);
        assert_eq!(row.kind, kind, "row {name}");
        assert!(row.loc >= 1);
        assert!(row.span.end > row.span.start);
    }
}

#[test]
fn adjacency_hint_is_syntactic_and_can_include_shadowed_sibling_names() {
    let src = "fn target() {}\nfn caller() { target(); }\nfn shadow() { let target = 1; let _ = target; }\nfn unrelated() {}\n";
    let exploded = explode(src).unwrap();
    assert_tiling(src, &exploded);

    assert_eq!(row(&exploded, "caller").adjacency_hint, vec!["target"]);
    assert_eq!(row(&exploded, "shadow").adjacency_hint, vec!["target"]);
    assert!(row(&exploded, "unrelated").adjacency_hint.is_empty());
}

#[test]
fn unparseable_input_returns_error_without_panic() {
    let error = explode("fn broken(").unwrap_err();
    assert!(error.to_string().contains("parse"));
}

#[test]
fn cli_explode_writes_chunks_and_manifest() {
    let temp = tempfile::tempdir().unwrap();
    let input = temp.path().join("input.rs");
    let out = temp.path().join("out");
    fs::write(&input, "fn a() {}\n\nfn b() {}\n").unwrap();

    let status = Command::new(env!("CARGO_BIN_EXE_rust-split"))
        .arg("explode")
        .arg(&input)
        .arg("--out")
        .arg(&out)
        .status()
        .unwrap();
    assert!(status.success());

    assert_eq!(
        fs::read_to_string(out.join("chunk-000.rs")).unwrap(),
        "fn a() {}\n\n"
    );
    assert_eq!(
        fs::read_to_string(out.join("chunk-001.rs")).unwrap(),
        "fn b() {}\n"
    );

    let manifest = fs::read_to_string(out.join("manifest.toml")).unwrap();
    assert!(manifest.contains("name = \"a\""));
    assert!(manifest.contains("name = \"b\""));
    assert!(manifest.contains("kind = \"fn\""));
}