rulewright 0.1.1

Executable engineering standards for Rust workspaces
Documentation
use crate::path::Path;
#[cfg(test)]
use googletest::prelude::*;
use ra_ap_syntax::ast::{self, HasVisibility, VisibilityKind};

use super::naming;
use crate::{AstCtx, Example, Violation};

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "pub struct repeats module name",
        code: "pub struct FooId;",
        pass: false,
    },
    Example {
        label: "pub enum repeats module name",
        code: "pub enum FooKind { A }",
        pass: false,
    },
    Example {
        label: "pub trait repeats module name",
        code: "pub trait FooLike {}",
        pass: false,
    },
    Example {
        label: "pub type alias repeats module name",
        code: "pub type FooResult = ();",
        pass: false,
    },
    Example {
        label: "name equal to module name",
        code: "pub struct Foo;",
        pass: true,
    },
    Example {
        label: "prefix is not a whole segment",
        code: "pub struct Food;",
        pass: true,
    },
    Example {
        label: "unrelated name",
        code: "pub struct Bar;",
        pass: true,
    },
    Example {
        label: "private item exempt",
        code: "struct FooId;",
        pass: true,
    },
    Example {
        label: "prefixed type in test module",
        code: "#[cfg(test)]\nmod tests {\n    pub struct FooFixture;\n}",
        pass: true,
    },
];

crate::ast_rule!(
    module_prefix_in_name,
    "Flag pub type definitions whose name repeats the module name as a prefix (`FooId` in `foo.rs`).",
    "Module-qualified APIs can avoid repeating the module in every public type. Flat re-exports and collision-prone APIs are intentional exceptions because this syntax-only rule cannot inspect the final exported namespace.",
    Low,
    default = false,
);

fn check_module_prefix_in_name(ctx: &AstCtx<'_>) -> Vec<Violation> {
    let Some(stem_parts) = stem_parts(ctx.file.rel) else {
        return Vec::new();
    };

    ctx.nodes::<ast::Item>()
        .filter(|item| !ctx.is_in_test(item) && is_pub(item))
        .filter_map(|item| {
            let name = naming::type_def_name(&item)?;
            let name_text = name.text();
            let segments = naming::segments(&name_text);
            let is_prefixed = segments.len() > stem_parts.len()
                && stem_parts
                    .iter()
                    .zip(&segments)
                    .all(|(part, segment)| part.eq_ignore_ascii_case(segment));

            is_prefixed.then(|| {
                ctx.violation(
                    &name,
                    format!(
                        "type name `{name_text}` repeats its module name — shorten only when callers use the module-qualified path; keep it when a flat re-export needs disambiguation"
                    ),
                )
            })
        })
        .collect()
}

fn stem_parts(rel: &str) -> Option<Vec<String>> {
    let path = Path::new(rel);
    let mut stem = path.file_stem()?.to_str()?;

    if stem == "mod" {
        stem = path.parent()?.file_name()?.to_str()?;
    }

    if stem == "lib" || stem == "main" {
        return None;
    }

    Some(
        stem.split('_')
            .filter(|part| !part.is_empty())
            .map(str::to_string)
            .collect(),
    )
}

fn is_pub(item: &ast::Item) -> bool {
    let visibility = match item {
        ast::Item::Struct(item) => item.visibility(),
        ast::Item::Enum(item) => item.visibility(),
        ast::Item::Trait(item) => item.visibility(),
        ast::Item::TypeAlias(item) => item.visibility(),
        _ => return false,
    };

    visibility.is_some_and(|visibility| matches!(visibility.kind(), VisibilityKind::Pub))
}

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

    fn run_at(rel: &str, source: &str) -> Vec<Violation> {
        crate::test_support::check_source_ast_at(rel, source, check_module_prefix_in_name)
    }

    #[gtest]
    fn examples() -> Result<()> {
        for ex in EXAMPLES {
            let violations = run_at("src/foo.rs", ex.code);

            verify_eq!(violations.is_empty(), ex.pass)?;
        }

        Ok(())
    }

    #[gtest]
    fn mod_rs_uses_parent_directory_name() -> Result<()> {
        verify_false!(run_at("src/widgets/mod.rs", "pub struct WidgetsId;").is_empty())?;
        verify_true!(run_at("src/widgets/mod.rs", "pub struct Widgets;").is_empty())?;

        Ok(())
    }

    #[gtest]
    fn snake_case_module_names_match_segment_wise() -> Result<()> {
        verify_false!(run_at("src/request_id.rs", "pub struct RequestIdMap;").is_empty())?;
        verify_true!(run_at("src/request_id.rs", "pub struct RequestId;").is_empty())?;

        Ok(())
    }

    #[gtest]
    fn lib_and_main_are_exempt() -> Result<()> {
        verify_true!(run_at("src/lib.rs", "pub struct LibId;").is_empty())?;
        verify_true!(run_at("src/main.rs", "pub struct MainLoop;").is_empty())?;

        Ok(())
    }
}