Skip to main content

wowlab_tidy/languages/rust/rules/macros/
proc_macro_thin_shim.rs

1use ra_ap_syntax::{
2    AstNode,
3    ast::{self, HasArgList, HasAttrs, HasName},
4};
5
6use crate::{AstCtx, Example, Violation};
7
8#[rustfmt::skip]
9const EXAMPLES: &[Example] = &[
10    Example {
11        label: "thin function-like shim",
12        code: "#[proc_macro]\npub fn my_macro(input: TokenStream) -> TokenStream {\n    my_macro_impl::my_macro(input.into()).into()\n}",
13        pass: true,
14    },
15    Example {
16        label: "thin attribute shim with two args",
17        code: "#[proc_macro_attribute]\npub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {\n    route_impl::route(attr.into(), item.into()).into()\n}",
18        pass: true,
19    },
20    Example {
21        label: "thin derive shim",
22        code: "#[proc_macro_derive(Model, attributes(model))]\npub fn derive_model(input: TokenStream) -> TokenStream {\n    model_impl::derive_model(input.into()).into()\n}",
23        pass: true,
24    },
25    Example {
26        label: "inline expansion logic",
27        code: "#[proc_macro]\npub fn my_macro(input: TokenStream) -> TokenStream {\n    let text = input.to_string();\n    text.parse().unwrap_or_default()\n}",
28        pass: false,
29    },
30    Example {
31        label: "delegation without impl crate",
32        code: "#[proc_macro]\npub fn my_macro(input: TokenStream) -> TokenStream {\n    expand(input)\n}",
33        pass: false,
34    },
35    Example {
36        label: "plain fn with any body",
37        code: "fn helper(input: String) -> String { input }",
38        pass: true,
39    },
40];
41
42crate::ast_rule!(
43    proc_macro_thin_shim,
44    "Require proc-macro entry points to be thin `impl_crate::name(arg.into()).into()` shims.",
45    "Token-stream logic living behind #[proc_macro] cannot be unit- or snapshot-tested — it belongs in a separate impl crate (M-PROC-IMPL).",
46    Low,
47);
48
49const PROC_MACRO_ATTRS: &[&str] = &["proc_macro", "proc_macro_derive", "proc_macro_attribute"];
50const MIN_DELEGATE_SEGMENTS: usize = 2;
51
52fn check_proc_macro_thin_shim(ctx: &AstCtx<'_>) -> Vec<Violation> {
53    ctx.nodes::<ast::Fn>()
54        .filter(|function| {
55            !ctx.is_in_test(function) && is_proc_macro_fn(function) && !is_thin_shim(function)
56        })
57        .map(|function| {
58            let name = function
59                .name()
60                .map_or_else(String::new, |name| name.text().to_string());
61
62            ctx.violation(
63                &function,
64                format!(
65                    "proc macro `{name}` is not a thin shim — delegate to a separate impl \
66                     crate: `foo_impl::{name}(input.into()).into()` (M-PROC-IMPL)"
67                ),
68            )
69        })
70        .collect()
71}
72
73fn is_proc_macro_fn(function: &ast::Fn) -> bool {
74    function.attrs().any(|attr| {
75        attr.simple_name()
76            .is_some_and(|name| PROC_MACRO_ATTRS.contains(&name.as_str()))
77    })
78}
79
80fn is_thin_shim(function: &ast::Fn) -> bool {
81    let Some(statements) = function.body().and_then(|body| body.stmt_list()) else {
82        return false;
83    };
84
85    if statements.statements().next().is_some() {
86        return false;
87    }
88
89    let Some(ast::Expr::MethodCallExpr(into_call)) = statements.tail_expr() else {
90        return false;
91    };
92
93    if into_call
94        .name_ref()
95        .is_none_or(|name| name.text() != "into")
96        || into_call
97            .arg_list()
98            .is_some_and(|args| args.args().next().is_some())
99    {
100        return false;
101    }
102
103    let Some(ast::Expr::CallExpr(delegate)) = into_call.receiver() else {
104        return false;
105    };
106    let Some(ast::Expr::PathExpr(path_expr)) = delegate.expr() else {
107        return false;
108    };
109
110    if path_expr
111        .path()
112        .is_none_or(|path| path.syntax().to_string().split("::").count() < MIN_DELEGATE_SEGMENTS)
113    {
114        return false;
115    }
116
117    delegate.arg_list().is_some_and(|arguments| {
118        arguments
119            .args()
120            .all(|argument| is_into_adapted_arg(&argument))
121    })
122}
123
124fn is_into_adapted_arg(arg: &ast::Expr) -> bool {
125    match arg {
126        ast::Expr::PathExpr(_) => true,
127        ast::Expr::MethodCallExpr(call) => {
128            call.name_ref().is_some_and(|name| name.text() == "into")
129                && call
130                    .arg_list()
131                    .is_none_or(|args| args.args().next().is_none())
132                && matches!(call.receiver(), Some(ast::Expr::PathExpr(_)))
133        }
134        _ => false,
135    }
136}
137
138crate::tidy_ast_test!(check_proc_macro_thin_shim, {
139    crate::example_tests!(EXAMPLES, check_proc_macro_thin_shim);
140});