Skip to main content

wowlab_tidy/languages/rust/rules/style/
pub_use_position.rs

1#[cfg(test)]
2use googletest::prelude::*;
3use ra_ap_syntax::{
4    AstNode, SourceFile,
5    ast::{self, HasAttrs, HasModuleItem, HasVisibility},
6    syntax_editor::SyntaxEditor,
7};
8
9use super::super::support::parse_use;
10use crate::{AstCtx, Example, Violation};
11
12const BLANK_LINE_NEWLINES: usize = 2;
13
14#[rustfmt::skip]
15const EXAMPLES: &[Example] = &[
16    Example {
17        label: "plain imports before separated public imports",
18        code: "use std::fmt;\nuse std::io;\n\npub use crate::api::Thing;\npub use crate::api::Other;",
19        pass: true,
20    },
21    Example {
22        label: "public import before plain import",
23        code: "pub use crate::api::Thing;\nuse std::fmt;",
24        pass: false,
25    },
26    Example {
27        label: "blocks need a blank line",
28        code: "use std::fmt;\npub use crate::api::Thing;",
29        pass: false,
30    },
31    Example {
32        label: "inline module is exempt",
33        code: "mod inner {\n    pub use crate::api::Thing;\n    use std::fmt;\n}",
34        pass: true,
35    },
36    Example {
37        label: "only public imports",
38        code: "pub use crate::api::Thing;\npub use crate::api::Other;",
39        pass: true,
40    },
41];
42
43crate::ast_tree_rule!(
44    pub_use_position,
45    "Require top-level public imports to follow plain imports in a separate block.",
46    "Keeping imports and re-exports in distinct leading blocks makes module API boundaries visible.",
47    Low,
48    fix_pub_use_position,
49);
50
51fn check_pub_use_position(ctx: &AstCtx<'_>) -> Vec<Violation> {
52    let uses = leading_uses(ctx.root);
53    let plain_count = uses.iter().filter(|item| !is_public(item)).count();
54
55    if plain_count == 0 || plain_count == uses.len() {
56        return Vec::new();
57    }
58
59    let ordered = uses
60        .iter()
61        .enumerate()
62        .all(|(index, item)| is_public(item) == (index >= plain_count));
63    let (plain, public) = uses.split_at(plain_count);
64    let separated = ordered
65        && plain
66            .last()
67            .zip(public.first())
68            .is_some_and(|(before, after)| has_blank_line(ctx, before, after));
69
70    if ordered && separated {
71        return Vec::new();
72    }
73
74    uses.get(plain_count.min(uses.len().saturating_sub(1)))
75        .map(|item| {
76            vec![ctx.violation(
77                item,
78                "public imports must follow all plain imports in a blank-line-separated block",
79            )]
80        })
81        .unwrap_or_default()
82}
83
84fn leading_uses(root: &SourceFile) -> Vec<ast::Use> {
85    let items: Vec<ast::Item> = root.items().collect();
86    let Some(start) = items
87        .iter()
88        .position(|item| matches!(item, ast::Item::Use(_)))
89    else {
90        return Vec::new();
91    };
92
93    items
94        .get(start..)
95        .unwrap_or_default()
96        .iter()
97        .map_while(|item| match item {
98            ast::Item::Use(item) => Some(item.clone()),
99            _ => None,
100        })
101        .collect()
102}
103
104fn is_public(item: &ast::Use) -> bool {
105    item.visibility().is_some()
106}
107
108fn has_blank_line(ctx: &AstCtx<'_>, before: &ast::Use, after: &ast::Use) -> bool {
109    let start: usize = before.syntax().text_range().end().into();
110    let end: usize = after.syntax().text_range().start().into();
111
112    ctx.file
113        .contents
114        .get(start..end)
115        .is_some_and(|between| between.matches('\n').count() >= BLANK_LINE_NEWLINES)
116}
117
118// #t(fn: rust_alloc_in_loop) each public re-export needs a parsed rustfmt-stable replacement
119// #t(fn: rust_clone_in_loop) syntax-editor sorting requires detached copies of source items
120fn fix_pub_use_position(ctx: &AstCtx<'_>, _violations: &[Violation]) -> Option<String> {
121    let (editor, root) = SyntaxEditor::with_ast_node(ctx.root);
122    let uses = leading_uses(&root);
123    let plain_count = uses.iter().filter(|item| !is_public(item)).count();
124
125    if plain_count == 0 || plain_count == uses.len() {
126        return None;
127    }
128
129    let mut target = uses.clone();
130
131    target.sort_by_key(is_public);
132
133    for item in &mut target {
134        if is_public(item) && !has_rustfmt_skip(item) {
135            *item = parse_use(&format!("#[rustfmt::skip]\n{}", item.syntax()))?;
136        }
137    }
138
139    for (old, new) in uses.iter().zip(&target) {
140        if old != new {
141            editor.replace(old.syntax().clone(), new.syntax().clone_subtree());
142        }
143    }
144
145    if let Some(whitespace) = uses
146        .get(plain_count.saturating_sub(1))?
147        .syntax()
148        .next_sibling_or_token()
149        .and_then(ra_ap_syntax::NodeOrToken::into_token)
150        .filter(|token| token.kind().is_trivia())
151    {
152        editor.replace(whitespace, editor.make().whitespace("\n\n"));
153    }
154
155    Some(editor.finish().new_root().to_string())
156}
157
158fn has_rustfmt_skip(item: &ast::Use) -> bool {
159    item.attrs().any(|attr| {
160        attr.syntax()
161            .text()
162            .to_string()
163            .split_whitespace()
164            .collect::<String>()
165            == "#[rustfmt::skip]"
166    })
167}
168
169crate::tidy_ast_test!(check_pub_use_position, {
170    crate::example_tests!(EXAMPLES, check_pub_use_position);
171    crate::fix_tests!(ast_tree, check_pub_use_position, fix_pub_use_position);
172
173    #[gtest]
174    fn fix_marks_public_uses_to_keep_nightly_rustfmt_from_regrouping_them() -> Result<()> {
175        let source = "pub use crate::api::Thing;\nuse std::fmt;";
176        let fixed = crate::apply_ast_tree_fix(source, check_pub_use_position, fix_pub_use_position);
177
178        verify_true!(fixed.contains("#[rustfmt::skip]\npub use crate::api::Thing;"))?;
179
180        Ok(())
181    }
182});