Skip to main content

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

1#[cfg(test)]
2use googletest::prelude::*;
3use line_index::LineIndex;
4use ra_ap_syntax::{
5    AstNode, Edition, NodeOrToken, SourceFile, TextSize,
6    ast::{self, TokenTreeChildren},
7};
8
9use super::support::is_quote_call;
10use crate::{AstCtx, Example, Violation};
11
12#[rustfmt::skip]
13const EXAMPLES: &[Example] = &[
14    Example {
15        label: "third-party absolute path in quote body",
16        code: "fn expand() {\n    let _ = quote::quote! { impl ::serde::Serialize for MyType {} };\n}",
17        pass: false,
18    },
19    Example {
20        label: "core absolute path in quote body",
21        code: "fn expand() {\n    let _ = quote::quote! { impl ::core::fmt::Debug for #name {} };\n}",
22        pass: true,
23    },
24    Example {
25        label: "std absolute path in quote body",
26        code: "fn expand() {\n    let _ = quote::quote! { let v = ::std::vec::Vec::<u8>::new(); };\n}",
27        pass: true,
28    },
29    Example {
30        label: "workspace crate absolute path in quote body",
31        code: "fn expand() {\n    let _ = quote::quote! { ::wowlab_types::sim::SpellIdx::from_raw(#id) };\n}",
32        pass: true,
33    },
34    Example {
35        label: "third-party absolute path in quote_spanned body",
36        code: "fn expand(span: Span) {\n    let _ = quote::quote_spanned! {span=> ::bytemuck::cast(#value) };\n}",
37        pass: false,
38    },
39    Example {
40        label: "third-party absolute path in macro_rules body",
41        code: "macro_rules! ser {\n    ($t:ty) => { impl ::serde::Serialize for $t {} };\n}",
42        pass: false,
43    },
44    Example {
45        label: "hardcoded host crate in macro_rules body",
46        code: "macro_rules! make {\n    () => { wowlab_engine_macros::helper() };\n}",
47        pass: false,
48    },
49    Example {
50        label: "dollar crate in macro_rules body",
51        code: "macro_rules! make {\n    () => { $crate::helper() };\n}",
52        pass: true,
53    },
54    Example {
55        label: "other workspace crate in macro_rules body",
56        code: "macro_rules! id {\n    ($v:expr) => { ::wowlab_types::sim::SpellIdx::from_raw($v) };\n}",
57        pass: true,
58    },
59    Example {
60        label: "absolute path outside macro bodies",
61        code: "fn f() {\n    let _ = <MyType as ::serde::Serialize>::serialize;\n}",
62        pass: true,
63    },
64    Example {
65        label: "opener in comment",
66        code: "// quote! { impl ::serde::Serialize for MyType {} }",
67        pass: true,
68    },
69];
70
71crate::ast_rule!(
72    macro_third_party_path,
73    "Flag absolute third-party paths in macro bodies and hardcoded host-crate paths in macro_rules!.",
74    "Emitted code must resolve inside the user's crate: third-party items go through a `#[doc(hidden)] pub mod _private` re-export and self-references through `$crate` (M-MACRO-HELPERS).",
75    Medium,
76);
77
78const ALLOWED_ROOTS: &[&str] = &["core", "std", "alloc", "_private"];
79const WORKSPACE_ROOT_PREFIX: &str = "wowlab_";
80
81enum Finding {
82    ThirdParty(String),
83    HostCrate,
84}
85
86fn check_macro_third_party_path(ctx: &AstCtx<'_>) -> Vec<Violation> {
87    let host = host_crate_root(ctx.file.rel);
88    let quote_fragments = ctx
89        .nodes::<ast::MacroCall>()
90        .filter(|call| !ctx.is_in_test(call) && is_quote_call(call))
91        .filter_map(|call| quote_fragment(ctx, &call));
92    let macro_rules_fragments = ctx
93        .nodes::<ast::MacroRules>()
94        .filter(|rules| !ctx.is_in_test(rules))
95        .flat_map(|rules| expansion_fragments(ctx, &rules));
96    let mut findings = Vec::new();
97
98    for fragment in quote_fragments {
99        scan_fragment(&fragment, None, &mut findings);
100    }
101
102    for fragment in macro_rules_fragments {
103        scan_fragment(&fragment, host.as_deref(), &mut findings);
104    }
105
106    findings
107        .into_iter()
108        .map(|(lineno, finding)| match finding {
109            Finding::ThirdParty(root) => crate::violation(
110                ctx.file.rel,
111                lineno,
112                format!(
113                    "macro body emits third-party absolute path `::{root}::` — re-export it \
114                     through a `#[doc(hidden)] pub mod _private` (M-MACRO-HELPERS)"
115                ),
116            ),
117            Finding::HostCrate => crate::violation(
118                ctx.file.rel,
119                lineno,
120                "macro_rules! body hardcodes the defining crate's name — use `$crate::` \
121                 (M-MACRO-HELPERS)",
122            ),
123        })
124        .collect()
125}
126
127fn host_crate_root(rel: &str) -> Option<String> {
128    let rest = rel.strip_prefix("crates/")?;
129    let (dir, _) = rest.split_once('/')?;
130
131    Some(format!("{WORKSPACE_ROOT_PREFIX}{}", dir.replace('-', "_")))
132}
133
134struct Fragment {
135    source: String,
136    first_line: usize,
137}
138
139fn quote_fragment(ctx: &AstCtx<'_>, call: &ast::MacroCall) -> Option<Fragment> {
140    let tree = call.token_tree()?;
141    let name = call.path()?.segment()?.name_ref()?.text().to_string();
142    let start = if name == "quote_spanned" {
143        after_fat_arrow(&tree)?
144    } else {
145        opening_delimiter(&tree)?.text_range().end()
146    };
147
148    fragment_between(ctx, &tree, start)
149}
150
151fn expansion_fragments(ctx: &AstCtx<'_>, rules: &ast::MacroRules) -> Vec<Fragment> {
152    let Some(tree) = rules.token_tree() else {
153        return Vec::new();
154    };
155    let mut after_arrow = false;
156    let mut saw_equals = false;
157    let mut fragments = Vec::new();
158
159    for element in TokenTreeChildren::new(&tree) {
160        match element {
161            NodeOrToken::Token(token) if token.text() == "=>" => after_arrow = true,
162            NodeOrToken::Token(token) if token.text() == "=" => saw_equals = true,
163            NodeOrToken::Token(token) if saw_equals && token.text() == ">" => {
164                after_arrow = true;
165                saw_equals = false;
166            }
167            NodeOrToken::Node(expansion) if after_arrow => {
168                if let Some(start) =
169                    opening_delimiter(&expansion).map(|token| token.text_range().end())
170                    && let Some(fragment) = fragment_between(ctx, &expansion, start)
171                {
172                    fragments.push(fragment);
173                }
174
175                after_arrow = false;
176            }
177            _ => saw_equals = false,
178        }
179    }
180
181    fragments
182}
183
184fn after_fat_arrow(tree: &ast::TokenTree) -> Option<TextSize> {
185    let mut saw_equals = false;
186
187    for element in TokenTreeChildren::new(tree) {
188        let NodeOrToken::Token(token) = element else {
189            saw_equals = false;
190            continue;
191        };
192
193        if token.text() == "=>" {
194            return Some(token.text_range().end());
195        }
196
197        if saw_equals && token.text() == ">" {
198            return Some(token.text_range().end());
199        }
200
201        saw_equals = token.text() == "=";
202    }
203
204    None
205}
206
207fn opening_delimiter(tree: &ast::TokenTree) -> Option<ra_ap_syntax::SyntaxToken> {
208    tree.l_paren_token()
209        .or_else(|| tree.l_brack_token())
210        .or_else(|| tree.l_curly_token())
211}
212
213fn closing_delimiter(tree: &ast::TokenTree) -> Option<ra_ap_syntax::SyntaxToken> {
214    tree.r_paren_token()
215        .or_else(|| tree.r_brack_token())
216        .or_else(|| tree.r_curly_token())
217}
218
219fn fragment_between(ctx: &AstCtx<'_>, tree: &ast::TokenTree, start: TextSize) -> Option<Fragment> {
220    let end = closing_delimiter(tree)?.text_range().start();
221    let source = ctx
222        .file
223        .contents
224        .get(u32::from(start) as usize..u32::from(end) as usize)?
225        .to_owned();
226    let first_line = ctx.line_index.line_col(start).line as usize + 1;
227
228    Some(Fragment { source, first_line })
229}
230
231fn scan_fragment(
232    fragment: &Fragment,
233    host_root: Option<&str>,
234    findings: &mut Vec<(usize, Finding)>,
235) {
236    let source = format!("fn __tidy_macro_fragment() {{ {} }}", fragment.source);
237    let parse = SourceFile::parse(&source, Edition::Edition2024);
238    let root = parse.tree();
239    let line_index = LineIndex::new(&source);
240
241    for path in root
242        .syntax()
243        .descendants()
244        .filter_map(ast::Path::cast)
245        .filter(maximal_path)
246    {
247        scan_path(fragment, host_root, &line_index, &path, findings);
248    }
249}
250
251fn scan_path(
252    fragment: &Fragment,
253    host_root: Option<&str>,
254    line_index: &LineIndex,
255    path: &ast::Path,
256    findings: &mut Vec<(usize, Finding)>,
257) {
258    let Some((root, absolute, qualified)) = path_root(path) else {
259        return;
260    };
261    let root = root.text();
262    let relative_line = line_index.line_col(path.syntax().text_range().start()).line as usize;
263    let lineno = fragment.first_line + relative_line;
264
265    if absolute
266        && qualified
267        && !ALLOWED_ROOTS.contains(&root.as_str())
268        && !root.starts_with(WORKSPACE_ROOT_PREFIX)
269    {
270        findings.push((lineno, Finding::ThirdParty(root.to_string())));
271    }
272
273    if qualified && host_root.is_some_and(|host| root == host) {
274        findings.push((lineno, Finding::HostCrate));
275    }
276}
277
278fn maximal_path(path: &ast::Path) -> bool {
279    path.syntax()
280        .parent()
281        .is_none_or(|parent| !ast::Path::can_cast(parent.kind()))
282}
283
284fn path_root(path: &ast::Path) -> Option<(ast::NameRef, bool, bool)> {
285    let absolute = path
286        .syntax()
287        .first_token()
288        .is_some_and(|token| token.text() == "::");
289    let mut segments = path.segments();
290    let root = segments.next()?.name_ref()?;
291    let qualified = segments.next().is_some();
292
293    Some((root, absolute, qualified))
294}
295
296// Host-crate check needs the file path, so examples run through the rel-aware helper.
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    const MACRO_CRATE_REL: &str = "crates/engine-macros/src/expand.rs";
302
303    #[gtest]
304    fn examples() -> Result<()> {
305        for ex in EXAMPLES {
306            let violations = crate::test_support::check_source_ast_at(
307                MACRO_CRATE_REL,
308                ex.code,
309                check_macro_third_party_path,
310            );
311
312            verify_eq!(violations.is_empty(), ex.pass)?;
313        }
314
315        Ok(())
316    }
317
318    #[gtest]
319    fn host_reference_to_other_crate_root_is_flagged_only_for_host() -> Result<()> {
320        let source =
321            "macro_rules! make {\n    () => { wowlab_types::sim::SpellIdx::from_raw(1) };\n}";
322        let from_types = crate::test_support::check_source_ast_at(
323            "crates/types/src/lib.rs",
324            source,
325            check_macro_third_party_path,
326        );
327
328        verify_false!(from_types.is_empty())?;
329        let from_other = crate::test_support::check_source_ast_at(
330            MACRO_CRATE_REL,
331            source,
332            check_macro_third_party_path,
333        );
334
335        verify_true!(from_other.is_empty())?;
336
337        Ok(())
338    }
339}