wowlab_tidy/languages/rust/rules/api/
single_item_path.rs1#[cfg(test)]
2use googletest::prelude::*;
3use ra_ap_syntax::{
4 AstNode, SyntaxNode,
5 ast::{self, HasAttrs, HasName, HasVisibility, VisibilityKind},
6};
7use wowlab_types::sim::FastSet;
8
9use crate::{AstCtx, Example, Violation};
10
11#[rustfmt::skip]
12const EXAMPLES: &[Example] = &[
13 Example {
14 label: "pub mod with pub use reexport",
15 code: "pub mod db {\n pub struct Connection;\n}\npub use db::Connection;",
16 pass: false,
17 },
18 Example {
19 label: "pub crate mod with pub use",
20 code: "pub(crate) mod db {\n pub struct Connection;\n}\npub use db::Connection;",
21 pass: true,
22 },
23 Example {
24 label: "pub mod with private use",
25 code: "pub mod db {\n pub struct Connection;\n}\nuse db::Connection;",
26 pass: true,
27 },
28 Example {
29 label: "self-prefixed reexport",
30 code: "pub mod db;\npub use self::db::Connection;",
31 pass: false,
32 },
33 Example {
34 label: "grouped reexport",
35 code: "pub mod db;\nmod other;\npub use {db::Connection, other::Helper};",
36 pass: false,
37 },
38 Example {
39 label: "renamed reexport",
40 code: "pub mod db;\npub use db::Connection as Conn;",
41 pass: false,
42 },
43 Example {
44 label: "module reexported under new name",
45 code: "pub mod db;\npub use db as database;",
46 pass: false,
47 },
48 Example {
49 label: "doc hidden module",
50 code: "#[doc(hidden)]\npub mod internals;\npub use internals::Helper;",
51 pass: true,
52 },
53 Example {
54 label: "underscore module",
55 code: "pub mod _private;\npub use _private::Helper;",
56 pass: true,
57 },
58 Example {
59 label: "reexport from private sibling",
60 code: "mod db {\n pub struct Connection;\n}\npub use db::Connection;",
61 pass: true,
62 },
63 Example {
64 label: "nested module siblings",
65 code: "pub mod outer {\n pub mod inner {\n pub struct A;\n }\n pub use inner::A;\n}",
66 pass: false,
67 },
68 Example {
69 label: "reexport in test module",
70 code: "#[cfg(test)]\nmod tests {\n pub mod db {\n pub struct Connection;\n }\n pub use db::Connection;\n}",
71 pass: true,
72 },
73];
74
75crate::ast_rule!(
76 single_item_path,
77 "Flag `pub use` re-exports that duplicate paths already public through a sibling `pub mod`.",
78 "Items reachable through two public paths clutter the API and confuse navigation; make the module non-pub or drop the re-export (M-SINGLE-ITEM-PATH).",
79 Medium,
80);
81
82fn check_single_item_path(ctx: &AstCtx<'_>) -> Vec<Violation> {
83 let mut out = Vec::new();
84
85 check_siblings(ctx, ctx.root.syntax(), &mut out);
86
87 for module in ctx
88 .nodes::<ast::Module>()
89 .filter(|module| !ctx.is_in_test(module))
90 {
91 if let Some(list) = module.item_list() {
92 check_siblings(ctx, list.syntax(), &mut out);
93 }
94 }
95
96 out
97}
98
99fn check_siblings(ctx: &AstCtx<'_>, scope: &SyntaxNode, out: &mut Vec<Violation>) {
100 let items: Vec<_> = scope.children().filter_map(ast::Item::cast).collect();
101 let mods = public_mod_names(&items);
102
103 if mods.is_empty() {
104 return;
105 }
106
107 for item in &items {
108 let ast::Item::Use(use_item) = item else {
109 continue;
110 };
111
112 if !is_pub(use_item.visibility()) || ctx.is_in_test(use_item) {
113 continue;
114 }
115
116 if let Some(tree) = use_item.use_tree() {
117 check_use_tree(ctx, &tree, &mods, out);
118 }
119 }
120}
121
122fn public_mod_names(items: &[ast::Item]) -> FastSet<String> {
124 let mut names = FastSet::default();
125
126 for item in items {
127 let ast::Item::Module(module) = item else {
128 continue;
129 };
130
131 if !is_pub(module.visibility()) {
132 continue;
133 }
134
135 let Some(name) = module.name().map(|name| name.text().to_string()) else {
136 continue;
137 };
138
139 if !name.starts_with('_') && !is_doc_hidden(module) {
140 names.insert(name);
141 }
142 }
143
144 names
145}
146
147fn is_doc_hidden(module: &ast::Module) -> bool {
148 module.attrs().any(|attr| {
149 attr.simple_name().as_deref() == Some("doc")
150 && attr.syntax().text().to_string().contains("hidden")
151 })
152}
153
154fn check_use_tree(
155 ctx: &AstCtx<'_>,
156 tree: &ast::UseTree,
157 mods: &FastSet<String>,
158 out: &mut Vec<Violation>,
159) {
160 let mut stack = vec![tree.clone()];
161
162 while let Some(tree) = stack.pop() {
163 if let Some(list) = tree.use_tree_list() {
164 stack.extend(list.use_trees());
165 continue;
166 }
167
168 if let Some(path) = tree.path() {
169 let mut current = Some(path);
170 let mut origin = None;
171
172 while let Some(path) = current {
173 if let Some(name) = path.segment().and_then(|segment| segment.name_ref()) {
174 if name.text() != "self" {
175 origin = Some(name);
176 }
177 }
178
179 current = path.qualifier();
180 }
181
182 if let Some(name_ref) = origin {
183 let name = name_ref.text();
184
185 if mods.contains(name.as_str()) {
186 out.push(dual_path_violation(ctx, &tree, name.as_str()));
187 }
188 }
189 }
190 }
191}
192
193fn dual_path_violation(ctx: &AstCtx<'_>, tree: &ast::UseTree, ident: &str) -> Violation {
194 ctx.violation(
195 tree,
196 format!(
197 "`pub use` re-exports through `pub mod {ident}` — items become public via two paths; make the module non-pub or drop the re-export (M-SINGLE-ITEM-PATH)"
198 ),
199 )
200}
201
202fn is_pub(visibility: Option<ast::Visibility>) -> bool {
203 visibility.is_some_and(|vis| matches!(vis.kind(), VisibilityKind::Pub))
204}
205
206crate::tidy_ast_test!(check_single_item_path, {
207 crate::example_tests!(EXAMPLES, check_single_item_path);
208
209 #[gtest]
210 fn separate_scopes_do_not_pair() -> Result<()> {
211 let v = run("pub mod a {\n\
212 mod db {\n\
213 pub struct Connection;\n\
214 }\n\
215 pub use db::Connection;\n\
216 }\n\
217 pub mod db;");
218 verify_true!(v.is_empty())?;
219
220 Ok(())
221 }
222});