wowlab_tidy/languages/rust/rules/style/
thiserror_qualified.rs1use ra_ap_syntax::{
2 AstNode, Edition, SourceFile,
3 ast::{self, HasVisibility},
4 syntax_editor::SyntaxEditor,
5};
6
7use super::super::support::parse_use;
8use crate::{AstCtx, Example, Violation};
9
10#[rustfmt::skip]
11const EXAMPLES: &[Example] = &[
12 Example {
13 label: "qualified derive",
14 code: "#[derive(Debug, thiserror::Error)]\n#[error(\"failed\")]\nstruct Failure;",
15 pass: true,
16 },
17 Example {
18 label: "single bare derive",
19 code: "use thiserror::Error;\n\n#[derive(Debug, Error)]\n#[error(\"failed\")]\nstruct Failure;",
20 pass: false,
21 },
22 Example {
23 label: "multiple bare derives",
24 code: "use thiserror::Error;\n\n#[derive(Error)]\n#[error(\"one\")]\nstruct One;\n#[derive(Error)]\n#[error(\"two\")]\nstruct Two;",
25 pass: false,
26 },
27 Example {
28 label: "import retained for a type use",
29 code: "use thiserror::Error;\n\n#[derive(Error)]\n#[error(\"one\")]\nstruct One;\nfn accepts(value: &dyn Error) {}",
30 pass: false,
31 },
32 Example {
33 label: "unrelated bare Error derive",
34 code: "#[derive(Error)]\nstruct Failure;",
35 pass: true,
36 },
37];
38
39crate::ast_tree_rule!(
40 thiserror_qualified,
41 "Require thiserror derives to use the qualified `thiserror::Error` path.",
42 "Qualified derives expose their provenance and avoid a file-wide trait import used only by attributes.",
43 Low,
44 fix_thiserror_qualified,
45);
46
47fn check_thiserror_qualified(ctx: &AstCtx<'_>) -> Vec<Violation> {
48 if !ctx.nodes::<ast::Use>().any(|item| imported_error(&item)) {
49 return Vec::new();
50 }
51
52 ctx.nodes::<ast::Attr>()
53 .filter(|attr| {
54 derive_entries(attr).is_some_and(|entries| entries.iter().any(|entry| entry == "Error"))
55 })
56 .map(|attr| {
57 ctx.violation(
58 &attr,
59 "thiserror derives must use `thiserror::Error`, not bare `Error`",
60 )
61 })
62 .collect()
63}
64
65fn derive_entries(attr: &ast::Attr) -> Option<Vec<String>> {
66 let (name, tokens) = attr.as_simple_call()?;
67
68 if name != "derive" {
69 return None;
70 }
71
72 let text = tokens.syntax().text().to_string();
73
74 Some(
75 text.strip_prefix('(')?
76 .strip_suffix(')')?
77 .split(',')
78 .map(str::trim)
79 .filter(|entry| !entry.is_empty())
80 .map(str::to_owned)
81 .collect(),
82 )
83}
84
85fn imported_error(item: &ast::Use) -> bool {
86 let Some(tree) = item.use_tree() else {
87 return false;
88 };
89 let Some(path) = tree.path() else {
90 return false;
91 };
92 let path_text = path
93 .syntax()
94 .text()
95 .to_string()
96 .replace(char::is_whitespace, "");
97
98 if path_text == "thiserror::Error" {
99 return true;
100 }
101
102 path_text == "thiserror"
103 && tree.use_tree_list().is_some_and(|list| {
104 list.use_trees().any(|child| {
105 child
106 .path()
107 .is_some_and(|path| path.syntax().text().to_string().trim() == "Error")
108 })
109 })
110}
111
112fn error_used_outside_import_or_derive(root: &SourceFile) -> bool {
113 root.syntax()
114 .descendants()
115 .filter_map(ast::NameRef::cast)
116 .filter(|name| name.text() == "Error")
117 .any(|name| {
118 !name.syntax().ancestors().any(|ancestor| {
119 ast::Use::can_cast(ancestor.kind())
120 || ast::Attr::cast(ancestor).is_some_and(|attr| {
121 derive_entries(&attr)
122 .is_some_and(|entries| entries.iter().any(|entry| entry == "Error"))
123 })
124 })
125 })
126}
127
128#[expect(
129 clippy::option_option,
130 reason = "the three states distinguish unrelated imports, deleted imports, and replacement imports"
131)]
132fn replacement_import(item: &ast::Use) -> Option<Option<ast::Use>> {
133 let tree = item.use_tree()?;
134 let path = tree.path()?;
135 let path_text = path
136 .syntax()
137 .text()
138 .to_string()
139 .replace(char::is_whitespace, "");
140
141 if path_text == "thiserror::Error" {
142 return Some(None);
143 }
144
145 if path_text != "thiserror" {
146 return None;
147 }
148
149 let remaining: Vec<String> = tree
150 .use_tree_list()?
151 .use_trees()
152 .filter(|child| {
153 child
154 .path()
155 .is_none_or(|path| path.syntax().text().to_string().trim() != "Error")
156 })
157 .map(|child| child.syntax().text().to_string())
158 .collect();
159
160 if remaining.is_empty() {
161 return Some(None);
162 }
163
164 let visibility = item
165 .visibility()
166 .map_or_else(String::new, |visibility| format!("{visibility} "));
167 let source = format!("{visibility}use thiserror::{{{}}};", remaining.join(", "));
168
169 parse_use(&source).map(Some)
170}
171
172fn fix_thiserror_qualified(ctx: &AstCtx<'_>, _violations: &[Violation]) -> Option<String> {
175 let (editor, root) = SyntaxEditor::with_ast_node(ctx.root);
176 let mut changed = false;
177
178 for attr in root.syntax().descendants().filter_map(ast::Attr::cast) {
179 let Some(mut entries) = derive_entries(&attr) else {
180 continue;
181 };
182 let mut rewritten = false;
183
184 for entry in &mut entries {
185 if entry == "Error" {
186 "thiserror::Error".clone_into(entry);
187 rewritten = true;
188 }
189 }
190
191 if rewritten {
192 let replacement = parse_attr(&format!("#[derive({})]", entries.join(", ")))?;
193
194 editor.replace(attr.syntax().clone(), replacement.syntax().clone_subtree());
195 changed = true;
196 }
197 }
198
199 if !error_used_outside_import_or_derive(&root) {
200 for item in root.syntax().descendants().filter_map(ast::Use::cast) {
201 if !imported_error(&item) {
202 continue;
203 }
204
205 match replacement_import(&item)? {
206 Some(replacement) => {
207 editor.replace(item.syntax().clone(), replacement.syntax().clone_subtree());
208 }
209 None => editor.delete(item.syntax().clone()),
210 }
211
212 changed = true;
213 }
214 }
215
216 changed.then(|| editor.finish().new_root().to_string())
217}
218
219fn parse_attr(source: &str) -> Option<ast::Attr> {
220 let parse = SourceFile::parse(&format!("{source}\nstruct Fixture;"), Edition::Edition2024);
221
222 parse
223 .errors()
224 .is_empty()
225 .then(|| parse.tree())?
226 .syntax()
227 .descendants()
228 .find_map(ast::Attr::cast)
229}
230
231crate::tidy_ast_test!(check_thiserror_qualified, {
232 crate::example_tests!(EXAMPLES, check_thiserror_qualified);
233 crate::fix_tests!(ast_tree, check_thiserror_qualified, fix_thiserror_qualified);
234});