rulewright 0.1.2

Executable engineering standards for Rust workspaces
Documentation
use std::collections::HashSet;

use ra_ap_syntax::{
    AstNode, AstToken, Edition, SourceFile,
    ast::{self, HasAttrs, HasName},
};

use crate::path::{Path, PathBuf};

#[derive(Debug)]
struct ModuleEdge {
    source: String,
    target: String,
    requires_test: bool,
}

pub(crate) fn discover(
    paths: &[PathBuf],
    root: &Path,
    test_roots: &HashSet<String>,
) -> HashSet<String> {
    let known: HashSet<String> = paths
        .iter()
        .filter(|path| path.extension().is_some_and(|extension| extension == "rs"))
        .filter_map(|path| relative(path, root))
        .collect();
    let mut edges = Vec::new();

    for path in paths
        .iter()
        .filter(|path| path.extension().is_some_and(|extension| extension == "rs"))
    {
        let Some(source) = relative(path, root) else {
            continue;
        };
        let Ok(contents) = crate::file::read_text(path) else {
            continue;
        };
        let parsed = SourceFile::parse(&contents, Edition::Edition2024);

        if !parsed.errors().is_empty() {
            continue;
        }

        collect_module_edges(path, &source, root, &parsed.tree(), &known, &mut edges);
        collect_include_edges(path, &source, root, &parsed.tree(), &known, &mut edges);
    }

    let mut test_files = test_roots
        .iter()
        .filter(|path| known.contains(*path))
        .cloned()
        .collect::<HashSet<_>>();

    loop {
        let before = test_files.len();

        for edge in &edges {
            if edge.requires_test || test_files.contains(&edge.source) {
                test_files.insert(edge.target.clone());
            }
        }

        if test_files.len() == before {
            break;
        }
    }

    test_files
}

fn collect_module_edges(
    path: &Path,
    source: &str,
    workspace_root: &Path,
    syntax: &SourceFile,
    known: &HashSet<String>,
    edges: &mut Vec<ModuleEdge>,
) {
    for module in syntax
        .syntax()
        .descendants()
        .filter_map(ast::Module::cast)
        .filter(|module| module.semicolon_token().is_some())
    {
        let candidates = module_candidates(path, &module);
        let Some(target) = candidates
            .iter()
            .filter_map(|candidate| candidate_relative(candidate, workspace_root))
            .find(|candidate| known.contains(candidate))
        else {
            continue;
        };

        edges.push(ModuleEdge {
            source: source.to_owned(),
            target,
            requires_test: super::syntax_is_in_test(module.syntax()),
        });
    }
}

fn collect_include_edges(
    path: &Path,
    source: &str,
    workspace_root: &Path,
    syntax: &SourceFile,
    known: &HashSet<String>,
    edges: &mut Vec<ModuleEdge>,
) {
    for call in syntax
        .syntax()
        .descendants()
        .filter_map(ast::MacroCall::cast)
        .filter(|call| {
            call.path()
                .and_then(|path| path.as_single_name_ref())
                .is_some_and(|name| name.text() == "include")
        })
    {
        let Some(included) = call
            .token_tree()
            .and_then(|tree| {
                tree.syntax()
                    .descendants_with_tokens()
                    .filter_map(ra_ap_syntax::NodeOrToken::into_token)
                    .find_map(ast::String::cast)
            })
            .and_then(|literal| literal.value().ok().map(std::borrow::Cow::into_owned))
        else {
            continue;
        };
        let Some(parent) = path.parent() else {
            continue;
        };
        let candidate = parent.join(&included);
        let Some(target) = relative(&candidate, workspace_root) else {
            continue;
        };

        if known.contains(&target) {
            edges.push(ModuleEdge {
                source: source.to_owned(),
                target,
                requires_test: super::syntax_is_in_test(call.syntax()),
            });
        }
    }
}

fn module_candidates(path: &Path, module: &ast::Module) -> Vec<PathBuf> {
    if let Some(configured) = path_attribute(module) {
        return path_attribute_directory(path, module)
            .map(|directory| vec![directory.join(configured)])
            .unwrap_or_default();
    }

    let Some(module_dir) = module_directory(path, module) else {
        return Vec::new();
    };

    let Some(name) = module.name().map(|name| name.text().to_string()) else {
        return Vec::new();
    };

    vec![
        module_dir.join(format!("{name}.rs")),
        module_dir.join(name).join("mod.rs"),
    ]
}

fn module_directory(path: &Path, module: &ast::Module) -> Option<PathBuf> {
    let mut module_dir = path.parent()?.to_path_buf();
    let stem = path.file_stem().and_then(|stem| stem.to_str());

    if !matches!(stem, Some("lib" | "main" | "mod"))
        && let Some(stem) = stem
    {
        module_dir.push(stem);
    }

    for ancestor in inline_ancestors(module) {
        module_dir.push(ancestor);
    }

    Some(module_dir)
}

fn path_attribute_directory(path: &Path, module: &ast::Module) -> Option<PathBuf> {
    let mut directory = path.parent()?.to_path_buf();

    for ancestor in inline_ancestors(module) {
        directory.push(ancestor);
    }

    Some(directory)
}

fn inline_ancestors(module: &ast::Module) -> Vec<String> {
    let mut ancestors: Vec<String> = module
        .syntax()
        .ancestors()
        .skip(1)
        .filter_map(ast::Module::cast)
        .filter(|ancestor| ancestor.item_list().is_some())
        .filter_map(|ancestor| ancestor.name().map(|name| name.text().to_string()))
        .collect();

    ancestors.reverse();

    ancestors
}

fn path_attribute(module: &ast::Module) -> Option<String> {
    let meta = module
        .attrs()
        .find(|attribute| attribute.simple_name().as_deref() == Some("path"))?
        .meta()?;
    let ast::Meta::KeyValueMeta(meta) = meta else {
        return None;
    };
    let expression = meta.expr()?;
    let ast::Expr::Literal(literal) = expression else {
        return None;
    };
    let string = literal.syntax().first_token().and_then(ast::String::cast)?;

    string.value().ok().map(std::borrow::Cow::into_owned)
}

fn relative(path: &Path, root: &Path) -> Option<String> {
    path.strip_prefix(root).ok()?.to_slash()
}

fn candidate_relative(path: &Path, root: &Path) -> Option<String> {
    let native: &std::path::Path = path.as_ref();
    let mut normalized = std::path::PathBuf::new();

    for component in native.components() {
        match component {
            std::path::Component::CurDir => {}

            std::path::Component::ParentDir => {
                normalized.pop();
            }

            other => normalized.push(other.as_os_str()),
        }
    }

    let normalized = PathBuf::from(normalized);

    relative(&normalized, root)
}

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

    use super::*;

    #[gtest]
    fn follows_cfg_test_path_modules_instead_of_filename_conventions() -> Result<()> {
        let directory = crate::temporary::Directory::new().or_fail()?;
        let source = directory.path().join("src/lib.rs");
        let split = directory.path().join("src/checks.rs");

        crate::directory::ensure(source.parent().or_fail()?).or_fail()?;
        crate::file::write_text(
            &source,
            "#[cfg(test)]\n#[path = \"checks.rs\"]\nmod validation;\n",
        )
        .or_fail()?;
        crate::file::write_text(&split, "fn test_only() {}\n").or_fail()?;

        let discovered = discover(&[source, split], directory.path(), &HashSet::new());

        verify_eq!(discovered, HashSet::from(["src/checks.rs".to_owned()]))
    }

    #[gtest]
    fn propagates_test_only_classification_to_child_modules_and_includes() -> Result<()> {
        let directory = crate::temporary::Directory::new().or_fail()?;
        let lib = directory.path().join("src/lib.rs");
        let split = directory.path().join("src/checks.rs");
        let child = directory.path().join("src/checks/cases.rs");
        let included = directory.path().join("src/inline_cases.rs");

        crate::directory::ensure(child.parent().or_fail()?).or_fail()?;
        crate::file::write_text(
            &lib,
            "#[cfg(test)]\n#[path = \"checks.rs\"]\nmod validation;\n\n#[cfg(test)]\nmod inline { include!(\"inline_cases.rs\"); }\n",
        )
        .or_fail()?;
        crate::file::write_text(&split, "mod cases;\n").or_fail()?;
        crate::file::write_text(&child, "fn child_test() {}\n").or_fail()?;
        crate::file::write_text(&included, "fn included_test() {}\n").or_fail()?;
        let paths = [lib, split, child, included];
        let discovered = discover(&paths, directory.path(), &HashSet::new());

        verify_eq!(
            discovered,
            HashSet::from([
                "src/checks.rs".to_owned(),
                "src/checks/cases.rs".to_owned(),
                "src/inline_cases.rs".to_owned(),
            ])
        )
    }

    #[gtest]
    fn path_attributes_follow_nested_module_directories_and_parent_segments() -> Result<()> {
        let directory = crate::temporary::Directory::new().or_fail()?;
        let lib = directory.path().join("src/lib.rs");
        let nested = directory.path().join("src/fixtures/checks.rs");
        let shared = directory.path().join("src/shared.rs");

        crate::directory::ensure(nested.parent().or_fail()?).or_fail()?;
        crate::file::write_text(
            &lib,
            "#[cfg(test)]\nmod fixtures {\n    #[path = \"checks.rs\"]\n    mod checks;\n    #[path = \"../shared.rs\"]\n    mod shared;\n}\n",
        )
        .or_fail()?;
        crate::file::write_text(&nested, "fn nested_test() {}\n").or_fail()?;
        crate::file::write_text(&shared, "fn shared_test() {}\n").or_fail()?;
        let discovered = discover(&[lib, nested, shared], directory.path(), &HashSet::new());

        verify_eq!(
            discovered,
            HashSet::from([
                "src/fixtures/checks.rs".to_owned(),
                "src/shared.rs".to_owned(),
            ])
        )
    }

    #[gtest]
    fn path_attribute_in_external_module_is_relative_to_its_source_directory() -> Result<()> {
        let directory = crate::temporary::Directory::new().or_fail()?;
        let lib = directory.path().join("src/lib.rs");
        let module = directory.path().join("src/navigation.rs");
        let split = directory.path().join("src/navigation_tests.rs");

        crate::directory::ensure(lib.parent().or_fail()?).or_fail()?;
        crate::file::write_text(&lib, "mod navigation;\n").or_fail()?;
        crate::file::write_text(
            &module,
            "#[cfg(test)]\n#[path = \"navigation_tests.rs\"]\nmod tests;\n",
        )
        .or_fail()?;
        crate::file::write_text(&split, "fn split_test() {}\n").or_fail()?;
        let discovered = discover(&[lib, module, split], directory.path(), &HashSet::new());

        verify_eq!(
            discovered,
            HashSet::from(["src/navigation_tests.rs".to_owned()])
        )
    }

    #[gtest]
    fn test_only_edges_follow_cfg_boolean_semantics() -> Result<()> {
        let directory = crate::temporary::Directory::new().or_fail()?;
        let lib = directory.path().join("src/lib.rs");
        let test_only = directory.path().join("src/test_only.rs");
        let mixed = directory.path().join("src/mixed.rs");

        crate::directory::ensure(lib.parent().or_fail()?).or_fail()?;
        crate::file::write_text(
            &lib,
            "#[cfg(all(test, feature = \"fixtures\"))]\nmod test_only;\n\n#[cfg(any(test, feature = \"fixtures\"))]\nmod mixed;\n",
        )
        .or_fail()?;
        crate::file::write_text(&test_only, "fn test_only() {}\n").or_fail()?;
        crate::file::write_text(&mixed, "fn mixed() {}\n").or_fail()?;
        let discovered = discover(&[lib, test_only, mixed], directory.path(), &HashSet::new());

        verify_eq!(discovered, HashSet::from(["src/test_only.rs".to_owned()]))
    }

    #[gtest]
    fn cargo_test_roots_propagate_to_child_modules() -> Result<()> {
        let directory = crate::temporary::Directory::new().or_fail()?;
        let integration = directory.path().join("tests/api.rs");
        let support = directory.path().join("tests/api/support.rs");

        crate::directory::ensure(support.parent().or_fail()?).or_fail()?;
        crate::file::write_text(&integration, "mod support;\n").or_fail()?;
        crate::file::write_text(&support, "fn fixture() {}\n").or_fail()?;
        let paths = [integration, support];
        let roots = HashSet::from(["tests/api.rs".to_owned()]);
        let discovered = discover(&paths, directory.path(), &roots);

        verify_eq!(
            discovered,
            HashSet::from(["tests/api.rs".to_owned(), "tests/api/support.rs".to_owned(),])
        )
    }
}