wowlab_tidy/languages/rust/rules/macros/
macro_third_party_path.rs1#[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#[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}