wowlab_tidy/languages/rust/rules/api/
map_err_pure_wrap.rs1use ra_ap_syntax::{AstNode, ast, ast::HasArgList};
2
3use crate::{AstCtx, Example, Violation};
4
5#[rustfmt::skip]
6const EXAMPLES: &[Example] = &[
7 Example {
8 label: "bare variant path",
9 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(AppError::Io) }",
10 pass: false,
11 },
12 Example {
13 label: "bare from path",
14 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(AppError::from) }",
15 pass: false,
16 },
17 Example {
18 label: "closure wrapping only the error",
19 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError::Io(e)) }",
20 pass: false,
21 },
22 Example {
23 label: "closure adding context arguments",
24 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError::io(e, \"config.toml\")) }",
25 pass: true,
26 },
27 Example {
28 label: "closure building struct context",
29 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError { source: e }) }",
30 pass: true,
31 },
32 Example {
33 label: "closure transforming the error",
34 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError::parse(e.to_string())) }",
35 pass: true,
36 },
37 Example {
38 label: "closure discarding the error",
39 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|_| AppError::Unknown) }",
40 pass: true,
41 },
42 Example {
43 label: "free fn argument",
44 code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(convert) }",
45 pass: true,
46 },
47 Example {
48 label: "no map_err",
49 code: "fn f(r: Result<u8, IoError>) -> Result<u8, IoError> { r }",
50 pass: true,
51 },
52 Example {
53 label: "pure wrap in test module",
54 code: "#[cfg(test)]\nmod tests {\n fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(AppError::Io) }\n}",
55 pass: true,
56 },
57];
58
59crate::ast_rule!(
60 map_err_pure_wrap,
61 "Flag `.map_err(...)` that only wraps the error in another type — implement `From` and let `?` convert.",
62 "Context-free error wrapping repeated at every call site obscures the happy path; a single From impl gives the conversion to every ? for free.",
63 Low,
64);
65
66fn check_map_err_pure_wrap(ctx: &AstCtx<'_>) -> Vec<Violation> {
67 ctx.nodes::<ast::MethodCallExpr>()
68 .filter(|call| !ctx.is_in_test(call))
69 .filter_map(|call| {
70 let method = call.name_ref()?;
71
72 if method.text() != "map_err" {
73 return None;
74 }
75
76 let args = call.arg_list()?;
77 let mut args = args.args();
78 let arg = args.next()?;
79
80 (args.next().is_none() && is_pure_wrap(&arg)).then(|| {
81 ctx.violation(
82 &method,
83 ".map_err() wraps the error without adding context — implement `From` and use `?`",
84 )
85 })
86 })
87 .collect()
88}
89
90fn is_pure_wrap(arg: &ast::Expr) -> bool {
91 match arg {
92 ast::Expr::PathExpr(path) => path.path().is_some_and(|path| is_ctor_like_path(&path)),
93 ast::Expr::ClosureExpr(closure) => closure_pure_wraps(closure),
94 _ => false,
95 }
96}
97
98fn is_ctor_like_path(path: &ast::Path) -> bool {
99 if path.qualifier().is_some() {
100 return true;
101 }
102
103 path.segment()
104 .and_then(|seg| seg.name_ref())
105 .is_some_and(|name| name.text().chars().next().is_some_and(char::is_uppercase))
106}
107
108fn closure_pure_wraps(closure: &ast::ClosureExpr) -> bool {
109 let normalized: String = closure
110 .syntax()
111 .text()
112 .to_string()
113 .chars()
114 .filter(|ch| !ch.is_whitespace())
115 .collect();
116 let Some(rest) = normalized.strip_prefix('|') else {
117 return false;
118 };
119 let Some((binding, body)) = rest.split_once('|') else {
120 return false;
121 };
122
123 if binding.is_empty() || binding == "_" || binding.contains(',') {
124 return false;
125 }
126
127 let body = body
128 .strip_prefix('{')
129 .and_then(|body| body.strip_suffix('}'))
130 .unwrap_or(body);
131 let Some(open) = body.find('(') else {
132 return false;
133 };
134 let Some(argument) = body.get(open + 1..body.len().saturating_sub(1)) else {
135 return false;
136 };
137
138 body.ends_with(')')
139 && argument == binding
140 && body
141 .get(..open)
142 .is_some_and(|constructor| !constructor.is_empty())
143}
144
145crate::tidy_ast_test!(check_map_err_pure_wrap, {
146 crate::example_tests!(EXAMPLES, check_map_err_pure_wrap);
147});