wowlab_tidy/languages/rust/rules/api/
builder_conventions.rs1use 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
80fn 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});