Skip to main content

wowlab_tidy/languages/rust/rules/api/
builder_conventions.rs

1use ra_ap_syntax::{
2    AstNode,
3    ast::{self, HasName, HasVisibility, VisibilityKind},
4};
5use wowlab_types::sim::{FastMap, FastSet};
6
7use super::support::type_name;
8use crate::{AstCtx, Example, Violation};
9
10#[rustfmt::skip]
11const EXAMPLES: &[Example] = &[
12    Example {
13        label: "conventional builder",
14        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}",
15        pass: true,
16    },
17    Example {
18        label: "builder without build method",
19        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}",
20        pass: false,
21    },
22    Example {
23        label: "public new on builder",
24        code: "pub struct ConnBuilder;\nimpl ConnBuilder {\n    pub fn new() -> Self {\n        ConnBuilder\n    }\n    pub fn build(self) -> u32 {\n        0\n    }\n}",
25        pass: false,
26    },
27    Example {
28        label: "set_ prefixed setter",
29        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}",
30        pass: false,
31    },
32    Example {
33        label: "with_ prefixed setter",
34        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}",
35        pass: false,
36    },
37    Example {
38        label: "borrowing setter",
39        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}",
40        pass: false,
41    },
42    Example {
43        label: "buildable type without builder shortcut",
44        code: "pub struct Conn;\npub struct ConnBuilder;\nimpl ConnBuilder {\n    pub fn build(self) -> Conn {\n        Conn\n    }\n}",
45        pass: false,
46    },
47    Example {
48        label: "builder without same-file impl",
49        code: "pub struct ConnBuilder;",
50        pass: true,
51    },
52    Example {
53        label: "private builder",
54        code: "struct ConnBuilder;\nimpl ConnBuilder {\n    fn new() -> Self {\n        ConnBuilder\n    }\n}",
55        pass: true,
56    },
57    Example {
58        label: "builder in test module",
59        code: "#[cfg(test)]\nmod tests {\n    pub struct ConnBuilder;\n    impl ConnBuilder {\n        pub fn new() -> Self {\n            ConnBuilder\n        }\n    }\n}",
60        pass: true,
61    },
62];
63
64crate::ast_rule!(
65    builder_conventions,
66    "Enforce builder conventions: chainable by-value setters named `x()`, a final `build()`, and `X::builder()` instead of `XBuilder::new()`.",
67    "Builders that deviate from the canonical pattern break fluent construction and surprise users who expect X::builder()...build().",
68    Medium,
69);
70
71fn check_builder_conventions(ctx: &AstCtx<'_>) -> Vec<Violation> {
72    let (pub_structs, methods) = collect_items(ctx);
73
74    ctx.nodes::<ast::Struct>()
75        .filter(|item| !ctx.is_in_test(item) && is_pub(item.visibility()))
76        .flat_map(|item| check_builder(ctx, &item, &pub_structs, &methods))
77        .collect()
78}
79
80// #t(fn: rust_alloc_in_loop) declaration names become owned lookup keys for the second analysis pass.
81fn collect_items(ctx: &AstCtx<'_>) -> (FastSet<String>, FastMap<String, Vec<ast::Fn>>) {
82    let mut structs = FastSet::default();
83    let mut methods: FastMap<String, Vec<ast::Fn>> = FastMap::default();
84
85    for item in ctx.root.syntax().children().filter_map(ast::Item::cast) {
86        match item {
87            ast::Item::Struct(item) if is_pub(item.visibility()) => {
88                if let Some(name) = item.name() {
89                    structs.insert(name.text().to_string());
90                }
91            }
92            ast::Item::Impl(item) if item.trait_().is_none() => {
93                let Some(name) = item.self_ty().and_then(|ty| type_name(&ty)) else {
94                    continue;
95                };
96
97                methods.entry(name).or_default().extend(
98                    item.assoc_item_list()
99                        .into_iter()
100                        .flat_map(|list| list.assoc_items())
101                        .filter_map(|assoc| match assoc {
102                            ast::AssocItem::Fn(function) => Some(function),
103                            _ => None,
104                        }),
105                );
106            }
107            _ => {}
108        }
109    }
110
111    (structs, methods)
112}
113
114fn is_chainable_setter(function: &ast::Fn, builder: &str) -> bool {
115    has_receiver(function)
116        && returns_self(function, builder)
117        && function.name().is_some_and(|name| name.text() != "build")
118}
119
120fn has_receiver(function: &ast::Fn) -> bool {
121    function
122        .param_list()
123        .is_some_and(|params| params.self_param().is_some())
124}
125
126fn takes_self_by_value(function: &ast::Fn) -> bool {
127    function
128        .param_list()
129        .and_then(|params| params.self_param())
130        .is_some_and(|receiver| receiver.amp_token().is_none())
131}
132
133fn returns_self(function: &ast::Fn, builder: &str) -> bool {
134    let Some(mut ty) = function.ret_type().and_then(|ret| ret.ty()) else {
135        return false;
136    };
137
138    if let ast::Type::RefType(reference) = ty {
139        let Some(inner) = reference.ty() else {
140            return false;
141        };
142
143        ty = inner;
144    }
145
146    type_name(&ty).is_some_and(|name| name == "Self" || name == builder)
147}
148
149fn is_builder_ctor_name(function: &ast::Fn) -> bool {
150    let Some(name) = function.name().map(|name| name.text().to_string()) else {
151        return false;
152    };
153
154    name == "builder" || name.starts_with("builder_")
155}
156
157fn check_builder(
158    ctx: &AstCtx<'_>,
159    item: &ast::Struct,
160    pub_structs: &FastSet<String>,
161    methods: &FastMap<String, Vec<ast::Fn>>,
162) -> Vec<Violation> {
163    let Some(name_node) = item.name() else {
164        return Vec::new();
165    };
166    let name = name_node.text().to_string();
167    let Some(base) = name.strip_suffix("Builder") else {
168        return Vec::new();
169    };
170    let Some(own) = methods.get(&name).filter(|own| !own.is_empty()) else {
171        return Vec::new();
172    };
173    let mut out = Vec::new();
174
175    if !own
176        .iter()
177        .any(|function| function.name().is_some_and(|name| name.text() == "build"))
178    {
179        out.push(ctx.violation(
180            &name_node,
181            format!("builder `{name}` has no `build()` method"),
182        ));
183    }
184
185    for function in own {
186        let Some(fn_node) = function.name() else {
187            continue;
188        };
189        let fn_name = fn_node.text();
190
191        if fn_name == "new" && is_pub(function.visibility()) {
192            out.push(ctx.violation(
193                &fn_node,
194                format!("`{name}::new` should not be public — provide `{base}::builder()` instead"),
195            ));
196        }
197
198        if fn_name.starts_with("set_") || fn_name.starts_with("with_") {
199            out.push(ctx.violation(&fn_node, format!("builder setter `{name}::{fn_name}` — setters are bare `x()`, not `set_x()`/`with_x()`")));
200        }
201
202        if is_chainable_setter(function, &name) && !takes_self_by_value(function) {
203            out.push(ctx.violation(
204                &fn_node,
205                format!("builder setter `{name}::{fn_name}` must take `self` by value to chain"),
206            ));
207        }
208    }
209
210    if !base.is_empty()
211        && pub_structs.contains(base)
212        && !methods
213            .get(base)
214            .is_some_and(|methods| methods.iter().any(is_builder_ctor_name))
215    {
216        out.push(ctx.violation(
217            &name_node,
218            format!("`{base}` should provide a `builder()` shortcut returning `{name}`"),
219        ));
220    }
221
222    out
223}
224
225fn is_pub(visibility: Option<ast::Visibility>) -> bool {
226    visibility.is_some_and(|vis| matches!(vis.kind(), VisibilityKind::Pub))
227}
228
229crate::tidy_ast_test!(check_builder_conventions, {
230    crate::example_tests!(EXAMPLES, check_builder_conventions);
231});