Skip to main content

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

1use 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});