wowlab_tidy/languages/rust/rules/style/
pub_use_position.rs1#[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
118fn 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});