rulewright 0.1.1

Executable engineering standards for Rust workspaces
Documentation
use ra_ap_syntax::{
    AstNode,
    ast::{self, HasName, HasVisibility, VisibilityKind},
};
use std::collections::{HashMap, HashSet};

use super::support::type_name;
use crate::{AstCtx, Example, Violation};

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "conventional builder",
        code: "pub struct Foo;\npub struct FooBuilder { size: u32 }\nimpl Foo {\n    pub fn builder() -> FooBuilder {\n        FooBuilder { size: 0 }\n    }\n}\nimpl FooBuilder {\n    pub fn size(mut self, size: u32) -> Self {\n        self.size = size;\n        self\n    }\n    pub fn build(self) -> Foo {\n        Foo\n    }\n}",
        pass: true,
    },
    Example {
        label: "builder without build method",
        code: "pub struct ConnBuilder { retries: u32 }\nimpl ConnBuilder {\n    pub fn retries(mut self, retries: u32) -> Self {\n        self.retries = retries;\n        self\n    }\n}",
        pass: false,
    },
    Example {
        label: "public new on builder",
        code: "pub struct ConnBuilder;\nimpl ConnBuilder {\n    pub fn new() -> Self {\n        ConnBuilder\n    }\n    pub fn build(self) -> u32 {\n        0\n    }\n}",
        pass: false,
    },
    Example {
        label: "set_ prefixed setter",
        code: "pub struct ConnBuilder { port: u16 }\nimpl ConnBuilder {\n    pub fn set_port(mut self, port: u16) -> Self {\n        self.port = port;\n        self\n    }\n    pub fn build(self) -> u16 {\n        self.port\n    }\n}",
        pass: false,
    },
    Example {
        label: "with_ prefixed setter",
        code: "pub struct ConnBuilder { port: u16 }\nimpl ConnBuilder {\n    pub fn with_port(mut self, port: u16) -> Self {\n        self.port = port;\n        self\n    }\n    pub fn build(self) -> u16 {\n        self.port\n    }\n}",
        pass: false,
    },
    Example {
        label: "borrowing setter",
        code: "pub struct ConnBuilder { port: u16 }\nimpl ConnBuilder {\n    pub fn port(&mut self, port: u16) -> &mut Self {\n        self.port = port;\n        self\n    }\n    pub fn build(self) -> u16 {\n        self.port\n    }\n}",
        pass: false,
    },
    Example {
        label: "buildable type without builder shortcut",
        code: "pub struct Conn;\npub struct ConnBuilder;\nimpl ConnBuilder {\n    pub fn build(self) -> Conn {\n        Conn\n    }\n}",
        pass: false,
    },
    Example {
        label: "builder without same-file impl",
        code: "pub struct ConnBuilder;",
        pass: true,
    },
    Example {
        label: "private builder",
        code: "struct ConnBuilder;\nimpl ConnBuilder {\n    fn new() -> Self {\n        ConnBuilder\n    }\n}",
        pass: true,
    },
    Example {
        label: "builder in test module",
        code: "#[cfg(test)]\nmod tests {\n    pub struct ConnBuilder;\n    impl ConnBuilder {\n        pub fn new() -> Self {\n            ConnBuilder\n        }\n    }\n}",
        pass: true,
    },
];

crate::ast_rule!(
    builder_conventions,
    "Enforce builder conventions: chainable by-value setters named `x()`, a final `build()`, and `X::builder()` instead of `XBuilder::new()`.",
    "Builders that deviate from the canonical pattern break fluent construction and surprise users who expect X::builder()...build().",
    Medium,
);

fn check_builder_conventions(ctx: &AstCtx<'_>) -> Vec<Violation> {
    let (pub_structs, methods) = collect_items(ctx);

    ctx.nodes::<ast::Struct>()
        .filter(|item| !ctx.is_in_test(item) && is_pub(item.visibility()))
        .flat_map(|item| check_builder(ctx, &item, &pub_structs, &methods))
        .collect()
}

// #rw(fn: rust_alloc_in_loop) declaration names become owned lookup keys for the second analysis pass.
fn collect_items(ctx: &AstCtx<'_>) -> (HashSet<String>, HashMap<String, Vec<ast::Fn>>) {
    let mut structs = HashSet::default();
    let mut methods: HashMap<String, Vec<ast::Fn>> = HashMap::default();

    for item in ctx.root.syntax().children().filter_map(ast::Item::cast) {
        match item {
            ast::Item::Struct(item) if is_pub(item.visibility()) => {
                if let Some(name) = item.name() {
                    structs.insert(name.text().to_string());
                }
            }
            ast::Item::Impl(item) if item.trait_().is_none() => {
                let Some(name) = item.self_ty().and_then(|ty| type_name(&ty)) else {
                    continue;
                };

                methods.entry(name).or_default().extend(
                    item.assoc_item_list()
                        .into_iter()
                        .flat_map(|list| list.assoc_items())
                        .filter_map(|assoc| match assoc {
                            ast::AssocItem::Fn(function) => Some(function),
                            _ => None,
                        }),
                );
            }
            _ => {}
        }
    }

    (structs, methods)
}

fn is_chainable_setter(function: &ast::Fn, builder: &str) -> bool {
    has_receiver(function)
        && returns_self(function, builder)
        && function.name().is_some_and(|name| name.text() != "build")
}

fn has_receiver(function: &ast::Fn) -> bool {
    function
        .param_list()
        .is_some_and(|params| params.self_param().is_some())
}

fn takes_self_by_value(function: &ast::Fn) -> bool {
    function
        .param_list()
        .and_then(|params| params.self_param())
        .is_some_and(|receiver| receiver.amp_token().is_none())
}

fn returns_self(function: &ast::Fn, builder: &str) -> bool {
    let Some(mut ty) = function.ret_type().and_then(|ret| ret.ty()) else {
        return false;
    };

    if let ast::Type::RefType(reference) = ty {
        let Some(inner) = reference.ty() else {
            return false;
        };

        ty = inner;
    }

    type_name(&ty).is_some_and(|name| name == "Self" || name == builder)
}

fn is_builder_ctor_name(function: &ast::Fn) -> bool {
    let Some(name) = function.name().map(|name| name.text().to_string()) else {
        return false;
    };

    name == "builder" || name.starts_with("builder_")
}

fn check_builder(
    ctx: &AstCtx<'_>,
    item: &ast::Struct,
    pub_structs: &HashSet<String>,
    methods: &HashMap<String, Vec<ast::Fn>>,
) -> Vec<Violation> {
    let Some(name_node) = item.name() else {
        return Vec::new();
    };
    let name = name_node.text().to_string();
    let Some(base) = name.strip_suffix("Builder") else {
        return Vec::new();
    };
    let Some(own) = methods.get(&name).filter(|own| !own.is_empty()) else {
        return Vec::new();
    };
    let mut out = Vec::new();

    if !own
        .iter()
        .any(|function| function.name().is_some_and(|name| name.text() == "build"))
    {
        out.push(ctx.violation(
            &name_node,
            format!("builder `{name}` has no `build()` method"),
        ));
    }

    for function in own {
        let Some(fn_node) = function.name() else {
            continue;
        };
        let fn_name = fn_node.text();

        if fn_name == "new" && is_pub(function.visibility()) {
            out.push(ctx.violation(
                &fn_node,
                format!("`{name}::new` should not be public — provide `{base}::builder()` instead"),
            ));
        }

        if fn_name.starts_with("set_") || fn_name.starts_with("with_") {
            out.push(ctx.violation(&fn_node, format!("builder setter `{name}::{fn_name}` — setters are bare `x()`, not `set_x()`/`with_x()`")));
        }

        if is_chainable_setter(function, &name) && !takes_self_by_value(function) {
            out.push(ctx.violation(
                &fn_node,
                format!("builder setter `{name}::{fn_name}` must take `self` by value to chain"),
            ));
        }
    }

    if !base.is_empty()
        && pub_structs.contains(base)
        && !methods
            .get(base)
            .is_some_and(|methods| methods.iter().any(is_builder_ctor_name))
    {
        out.push(ctx.violation(
            &name_node,
            format!("`{base}` should provide a `builder()` shortcut returning `{name}`"),
        ));
    }

    out
}

fn is_pub(visibility: Option<ast::Visibility>) -> bool {
    visibility.is_some_and(|vis| matches!(vis.kind(), VisibilityKind::Pub))
}

crate::rulewright_ast_test!(check_builder_conventions, {
    crate::example_tests!(EXAMPLES, check_builder_conventions);
});