Skip to main content

wowlab_tidy/languages/rust/rules/style/
thiserror_qualified.rs

1use 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
172// #t(fn: rust_alloc_in_loop) each derive and grouped import needs freshly formatted source
173// #t(fn: rust_clone_in_loop) syntax-editor replacements must be detached nodes
174fn 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});