Skip to main content

wowlab_tidy/languages/rust/rules/api/
single_item_path.rs

1#[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
122// #t(fn: rust_alloc_in_loop) sibling module names are owned map keys.
123fn 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});