Skip to main content

wowlab_tidy/languages/rust/rules/interop/
foreign_reexports.rs

1#[cfg(test)]
2use googletest::prelude::*;
3use ra_ap_syntax::{
4    AstNode,
5    ast::{self, HasAttrs, HasName},
6};
7use wowlab_types::sim::FastSet;
8
9use crate::{AstCtx, Example, Violation, infra::workspace::WorkspaceMembers};
10
11#[rustfmt::skip]
12const EXAMPLES: &[Example] = &[
13    Example {
14        label: "pub use of foreign crate item",
15        code: "pub use serde_json::Value;",
16        pass: false,
17    },
18    Example {
19        label: "pub use re-exporting foreign crate root",
20        code: "pub use rand;",
21        pass: false,
22    },
23    Example {
24        label: "foreign item in use group",
25        code: "pub use {std::fmt::Debug, chrono::Utc};",
26        pass: false,
27    },
28    Example {
29        label: "renamed foreign re-export",
30        code: "pub use serde_json::Value as JsonValue;",
31        pass: false,
32    },
33    Example {
34        label: "pub use of crate item",
35        code: "pub use crate::foo::Bar;",
36        pass: true,
37    },
38    Example {
39        label: "pub use of self path",
40        code: "pub use self::inner::Thing;",
41        pass: true,
42    },
43    Example {
44        label: "pub use of std item",
45        code: "pub use std::fmt::Debug;",
46        pass: true,
47    },
48    Example {
49        label: "pub use of workspace crate",
50        code: "pub use wowlab_types::game::SpecId;",
51        pass: true,
52    },
53    Example {
54        label: "pub use of workspace root alias",
55        code: "pub use common::output::Table;",
56        pass: true,
57    },
58    Example {
59        label: "non-pub use of foreign crate",
60        code: "use serde_json::Value;",
61        pass: true,
62    },
63    Example {
64        label: "pub(crate) use of foreign crate",
65        code: "pub(crate) use serde_json::Value;",
66        pass: true,
67    },
68    Example {
69        label: "doc(hidden) re-export",
70        code: "#[doc(hidden)]\npub use serde_json::Value;",
71        pass: true,
72    },
73    Example {
74        label: "re-export inside __private module",
75        code: "mod __private {\n    pub use serde_json::Value;\n}",
76        pass: true,
77    },
78    Example {
79        label: "re-export inside _private module",
80        code: "mod _private {\n    pub use serde_json::Value;\n}",
81        pass: true,
82    },
83    Example {
84        label: "re-export in test module",
85        code: "#[cfg(test)]\nmod tests {\n    pub use serde_json::Value;\n}",
86        pass: true,
87    },
88    Example {
89        label: "re-export from local module",
90        code: "mod inner {}\npub use inner::Thing;",
91        pass: true,
92    },
93    Example {
94        label: "re-export from local out-of-line module",
95        code: "pub mod infra;\npub use infra::Config;",
96        pass: true,
97    },
98    Example {
99        label: "grouped re-export from local module",
100        code: "mod inner {}\npub use inner::{Thing, Other};",
101        pass: true,
102    },
103    Example {
104        label: "grouped re-export from foreign crate",
105        code: "pub use serde_json::{Map, Value};",
106        pass: false,
107    },
108];
109
110crate::ast_rule!(
111    foreign_reexports,
112    "Flag `pub use` re-exports of items from foreign crates.",
113    "Re-exported foreign items blur type identity into aliases; users should depend on the defining crate directly (M-FOREIGN-REEXPORTS).",
114    Medium,
115    params {
116        allowed: [String] = []
117    },
118);
119
120const BUILTIN_ROOTS: &[&str] = &["crate", "self", "super", "std", "core", "alloc"];
121
122fn check_foreign_reexports(ctx: &AstCtx<'_>) -> Vec<Violation> {
123    let allowed = ctx
124        .file
125        .config
126        .get_str_array("rust_foreign_reexports", &PARAMS[0]);
127    let members = ctx.file.config.workspace().members(ctx.file.path);
128    let local_mods = local_modules(ctx);
129
130    let public_uses = ctx
131        .nodes::<ast::Use>()
132        .filter(|item| !ctx.is_in_test(item))
133        .filter(|item| !inside_private_module(item));
134
135    public_uses
136        .filter(|item| super::support::is_fully_public(item) && !is_doc_hidden(item))
137        .flat_map(|item| {
138            item.use_tree()
139                .into_iter()
140                .flat_map(collect_roots)
141                .filter_map(|root| {
142                    let name = root.text().to_string();
143
144                    (!is_internal_root(&name, &members)
145                        && !local_mods.contains(&name)
146                        && !allowed.contains(&name))
147                        .then(|| {
148                            ctx.violation(
149                                &root,
150                                format!(
151                                    "`pub use` re-exports foreign crate `{name}` — users should import it from `{name}` directly"
152                                ),
153                            )
154                        })
155                })
156                .collect::<Vec<_>>()
157        })
158        .collect()
159}
160
161fn local_modules(ctx: &AstCtx<'_>) -> FastSet<String> {
162    ctx.nodes::<ast::Module>()
163        .filter_map(|module| module.name().map(|name| name.text().to_string()))
164        .collect()
165}
166
167fn is_internal_root(root: &str, members: &WorkspaceMembers) -> bool {
168    BUILTIN_ROOTS.contains(&root) || members.is_member_root(root)
169}
170
171// #t(fn: rust_clone_in_loop) iterative traversal owns each qualified path while retaining name nodes
172fn collect_roots(tree: ast::UseTree) -> Vec<ast::NameRef> {
173    let mut out = Vec::new();
174    let mut stack = vec![tree];
175
176    while let Some(tree) = stack.pop() {
177        if let Some(path) = tree.path() {
178            let names = super::support::path_names(path.clone());
179
180            if let Some(root_name) = names.first()
181                && let Some(root) = path
182                    .syntax()
183                    .descendants()
184                    .filter_map(ast::NameRef::cast)
185                    .find(|name| name.text() == root_name.as_str())
186            {
187                out.push(root);
188            }
189        } else if let Some(list) = tree.use_tree_list() {
190            stack.extend(list.use_trees());
191        }
192    }
193
194    out
195}
196
197fn is_doc_hidden(item: &ast::Use) -> bool {
198    item.attrs().any(|attr| {
199        attr.simple_name().is_some_and(|name| name == "doc")
200            && attr
201                .syntax()
202                .descendants_with_tokens()
203                .filter_map(ra_ap_syntax::NodeOrToken::into_token)
204                .any(|token| token.text() == "hidden")
205    })
206}
207
208fn inside_private_module(item: &ast::Use) -> bool {
209    item.syntax()
210        .ancestors()
211        .skip(1)
212        .filter_map(ast::Module::cast)
213        .filter_map(|module| module.name())
214        .any(|name| matches!(name.text().as_str(), "_private" | "__private"))
215}
216
217crate::tidy_ast_test!(check_foreign_reexports, {
218    crate::example_tests!(EXAMPLES, check_foreign_reexports);
219
220    #[gtest]
221    fn qualified_group_reports_the_outer_root_once() -> Result<()> {
222        let violations = run("pub use serde_json::{Map, Value};");
223        verify_eq!(violations.len(), 1)?;
224        verify_true!(violations[0].message.contains("`serde_json`"))?;
225
226        Ok(())
227    }
228});