wowlab_tidy/languages/rust/rules/style/
alphabetical.rs1#[cfg(test)]
2use googletest::prelude::*;
3
4use crate::{Example, FileCtx, Fix, Violation, infra::parse, violation};
5
6const START: &str = "tidy-alphabetical-start";
7const END: &str = "tidy-alphabetical-end";
8const MIN_SORTABLE_LINES: usize = 2;
9
10#[rustfmt::skip]
11const EXAMPLES: &[Example] = &[
12 Example {
13 label: "sorted region",
14 code: "// tidy-alphabetical-start\nuse alpha;\nuse beta;\nuse gamma;\n// tidy-alphabetical-end",
15 pass: true,
16 },
17 Example {
18 label: "unsorted region",
19 code: "// tidy-alphabetical-start\nuse gamma;\nuse alpha;\n// tidy-alphabetical-end",
20 pass: false,
21 },
22 Example {
23 label: "no region",
24 code: "use zebra;\nuse alpha;",
25 pass: true,
26 },
27];
28
29crate::line_rule!(
30 alphabetical,
31 "Enforce sorted ordering in regions marked with `tidy-alphabetical-start`.",
32 "Sorted lists in marked regions prevent merge conflicts and make entries easy to locate.",
33 Low,
34 fix_alphabetical,
35);
36
37fn sort_key(line: &str) -> String {
38 use winnow::combinator::alt;
39
40 let mut input = line.trim();
41 let _ = parse::try_parse(&mut input, alt(("pub(crate) ", "pub(super) ", "pub ")));
42 let _ = parse::try_parse(&mut input, alt(("extern crate ", "use ", "mod ")));
43
44 input.to_lowercase()
45}
46
47fn check_alphabetical(ctx: &FileCtx<'_>) -> Vec<Violation> {
48 let mut out = Vec::new();
49 let mut depth: usize = 0;
50 let mut prev_key: Option<String> = None;
51 let mut region_start: usize = 0;
52
53 for (i, line) in ctx.lines.iter().enumerate() {
54 let lineno = i + 1;
55 let trimmed = line.trim();
56
57 if trimmed.contains(START) {
58 if depth == 0 {
59 prev_key = None;
60 region_start = lineno;
61 }
62
63 depth += 1;
64 continue;
65 }
66
67 if trimmed.contains(END) {
68 depth = depth.saturating_sub(1);
69
70 if depth == 0 {
71 prev_key = None;
72 }
73
74 continue;
75 }
76
77 if depth != 1 {
78 continue;
79 }
80
81 if trimmed.is_empty() || parse::is_comment(trimmed) {
82 continue;
83 }
84
85 let key = sort_key(trimmed);
86
87 if let Some(ref prev) = prev_key
88 && key < *prev
89 {
90 out.push(violation(
91 ctx.rel,
92 lineno,
93 format!("line not in alphabetical order (region started at line {region_start})"),
94 ));
95 }
96
97 prev_key = Some(key);
98 }
99
100 if depth > 0 {
101 out.push(violation(
102 ctx.rel,
103 region_start,
104 format!("unmatched `{START}` (missing `{END}`)"),
105 ));
106 }
107
108 out
109}
110
111fn fix_alphabetical(ctx: &FileCtx<'_>, v: &Violation) -> Option<Fix> {
112 if v.message.contains("unmatched") {
113 return None;
114 }
115
116 let (start_idx, end_idx) = alphabetical_region(ctx.lines, v.line)?;
117 let content_start = start_idx + 1;
118 let result_lines = sorted_region(ctx.lines.get(content_start..end_idx)?)?;
119
120 Some(Fix {
121 start_line: content_start + 1,
122 end_line: end_idx,
123 replacement: result_lines.join("\n"),
124 })
125}
126
127fn alphabetical_region(lines: &[&str], target_line: usize) -> Option<(usize, usize)> {
128 let start = lines
129 .iter()
130 .enumerate()
131 .take(target_line)
132 .rev()
133 .find_map(|(index, line)| line.trim().contains(START).then_some(index))?;
134 let end = lines
135 .iter()
136 .enumerate()
137 .skip(start + 1)
138 .find_map(|(index, line)| line.trim().contains(END).then_some(index))
139 .unwrap_or(lines.len());
140
141 (start < end).then_some((start, end))
142}
143
144fn sorted_region<'a>(lines: &[&'a str]) -> Option<Vec<&'a str>> {
145 let mut sorted: Vec<&str> = lines
146 .iter()
147 .copied()
148 .filter(|line| {
149 let trimmed = line.trim();
150
151 !trimmed.is_empty() && !parse::is_comment(trimmed)
152 })
153 .collect();
154
155 if sorted.len() < MIN_SORTABLE_LINES {
156 return None;
157 }
158
159 sorted.sort_by_key(|line| sort_key(line.trim()));
160 let mut sorted = sorted.into_iter();
161
162 lines
163 .iter()
164 .map(|line| {
165 let trimmed = line.trim();
166
167 if trimmed.is_empty() || parse::is_comment(trimmed) {
168 Some(*line)
169 } else {
170 sorted.next()
171 }
172 })
173 .collect()
174}
175
176crate::tidy_test!(check_alphabetical, {
177 crate::example_tests!(EXAMPLES, check_alphabetical);
178
179 #[gtest]
180 fn sorted_region_passes() -> Result<()> {
181 verify_true!(
182 run("// tidy-alphabetical-start\n\
183 use alpha;\n\
184 use beta;\n\
185 use gamma;\n\
186 // tidy-alphabetical-end")
187 .is_empty()
188 )?;
189
190 Ok(())
191 }
192
193 #[gtest]
194 fn unsorted_region_fails() -> Result<()> {
195 let v = run("// tidy-alphabetical-start\n\
196 use gamma;\n\
197 use alpha;\n\
198 // tidy-alphabetical-end");
199 verify_eq!(v.len(), 1)?;
200 verify_eq!(v[0].line, 3)?;
201
202 Ok(())
203 }
204
205 #[gtest]
206 fn ignores_comments_and_blanks() -> Result<()> {
207 verify_true!(
208 run("// tidy-alphabetical-start\n\
209 use alpha;\n\
210 \n\
211 // a comment\n\
212 use beta;\n\
213 // tidy-alphabetical-end")
214 .is_empty()
215 )?;
216
217 Ok(())
218 }
219
220 #[gtest]
221 fn strips_visibility_prefix() -> Result<()> {
222 verify_true!(
223 run("// tidy-alphabetical-start\n\
224 pub mod alpha;\n\
225 pub(crate) mod beta;\n\
226 mod gamma;\n\
227 // tidy-alphabetical-end")
228 .is_empty()
229 )?;
230
231 Ok(())
232 }
233
234 #[gtest]
235 fn unmatched_start() -> Result<()> {
236 let v = run("// tidy-alphabetical-start\nuse a;");
237 verify_eq!(v.len(), 1)?;
238 verify_true!(v[0].message.contains("unmatched"))?;
239
240 Ok(())
241 }
242
243 #[gtest]
244 fn nested_regions_are_opaque() -> Result<()> {
245 verify_true!(
246 run("// tidy-alphabetical-start\n\
247 use alpha;\n\
248 // tidy-alphabetical-start\n\
249 use zebra;\n\
250 use aardvark;\n\
251 // tidy-alphabetical-end\n\
252 use beta;\n\
253 // tidy-alphabetical-end")
254 .is_empty()
255 )?;
256
257 Ok(())
258 }
259
260 #[gtest]
261 fn no_region_no_violations() -> Result<()> {
262 verify_true!(run("use zebra;\nuse alpha;").is_empty())?;
263
264 Ok(())
265 }
266
267 #[gtest]
268 fn fix_sorts_region() -> Result<()> {
269 let source = "// tidy-alphabetical-start\n\
270 use gamma;\n\
271 use alpha;\n\
272 use beta;\n\
273 // tidy-alphabetical-end";
274 let fixed = crate::apply_line_fixes(source, check_alphabetical, fix_alphabetical);
275 let remaining = run(&fixed);
276 verify_true!(remaining.is_empty())?;
277 let lines: Vec<&str> = fixed.lines().collect();
278 verify_eq!(lines[1].trim(), "use alpha;")?;
279 verify_eq!(lines[2].trim(), "use beta;")?;
280 verify_eq!(lines[3].trim(), "use gamma;")?;
281
282 Ok(())
283 }
284
285 #[gtest]
286 fn fix_preserves_comments() -> Result<()> {
287 let source = "// tidy-alphabetical-start\n\
288 use gamma;\n\
289 // a comment\n\
290 use alpha;\n\
291 // tidy-alphabetical-end";
292 let fixed = crate::apply_line_fixes(source, check_alphabetical, fix_alphabetical);
293 let remaining = run(&fixed);
294 verify_true!(remaining.is_empty())?;
295
296 Ok(())
297 }
298});