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 if key < *prev {
89 out.push(violation(
90 ctx.rel,
91 lineno,
92 format!(
93 "line not in alphabetical order (region started at line {region_start})"
94 ),
95 ));
96 }
97 }
98
99 prev_key = Some(key);
100 }
101
102 if depth > 0 {
103 out.push(violation(
104 ctx.rel,
105 region_start,
106 format!("unmatched `{START}` (missing `{END}`)"),
107 ));
108 }
109
110 out
111}
112
113fn fix_alphabetical(ctx: &FileCtx<'_>, v: &Violation) -> Option<Fix> {
114 if v.message.contains("unmatched") {
115 return None;
116 }
117
118 let (start_idx, end_idx) = alphabetical_region(ctx.lines, v.line)?;
119 let content_start = start_idx + 1;
120 let result_lines = sorted_region(ctx.lines.get(content_start..end_idx)?)?;
121
122 Some(Fix {
123 start_line: content_start + 1,
124 end_line: end_idx,
125 replacement: result_lines.join("\n"),
126 })
127}
128
129fn alphabetical_region(lines: &[&str], target_line: usize) -> Option<(usize, usize)> {
130 let start = lines
131 .iter()
132 .enumerate()
133 .take(target_line)
134 .rev()
135 .find_map(|(index, line)| line.trim().contains(START).then_some(index))?;
136 let end = lines
137 .iter()
138 .enumerate()
139 .skip(start + 1)
140 .find_map(|(index, line)| line.trim().contains(END).then_some(index))
141 .unwrap_or(lines.len());
142
143 (start < end).then_some((start, end))
144}
145
146fn sorted_region<'a>(lines: &[&'a str]) -> Option<Vec<&'a str>> {
147 let mut sorted: Vec<&str> = lines
148 .iter()
149 .copied()
150 .filter(|line| {
151 let trimmed = line.trim();
152
153 !trimmed.is_empty() && !parse::is_comment(trimmed)
154 })
155 .collect();
156
157 if sorted.len() < MIN_SORTABLE_LINES {
158 return None;
159 }
160
161 sorted.sort_by_key(|line| sort_key(line.trim()));
162 let mut sorted = sorted.into_iter();
163
164 lines
165 .iter()
166 .map(|line| {
167 let trimmed = line.trim();
168
169 if trimmed.is_empty() || parse::is_comment(trimmed) {
170 Some(*line)
171 } else {
172 sorted.next()
173 }
174 })
175 .collect()
176}
177
178crate::tidy_test!(check_alphabetical, {
179 crate::example_tests!(EXAMPLES, check_alphabetical);
180
181 #[gtest]
182 fn sorted_region_passes() -> Result<()> {
183 verify_true!(
184 run("// tidy-alphabetical-start\n\
185 use alpha;\n\
186 use beta;\n\
187 use gamma;\n\
188 // tidy-alphabetical-end")
189 .is_empty()
190 )?;
191
192 Ok(())
193 }
194
195 #[gtest]
196 fn unsorted_region_fails() -> Result<()> {
197 let v = run("// tidy-alphabetical-start\n\
198 use gamma;\n\
199 use alpha;\n\
200 // tidy-alphabetical-end");
201 verify_eq!(v.len(), 1)?;
202 verify_eq!(v[0].line, 3)?;
203
204 Ok(())
205 }
206
207 #[gtest]
208 fn ignores_comments_and_blanks() -> Result<()> {
209 verify_true!(
210 run("// tidy-alphabetical-start\n\
211 use alpha;\n\
212 \n\
213 // a comment\n\
214 use beta;\n\
215 // tidy-alphabetical-end")
216 .is_empty()
217 )?;
218
219 Ok(())
220 }
221
222 #[gtest]
223 fn strips_visibility_prefix() -> Result<()> {
224 verify_true!(
225 run("// tidy-alphabetical-start\n\
226 pub mod alpha;\n\
227 pub(crate) mod beta;\n\
228 mod gamma;\n\
229 // tidy-alphabetical-end")
230 .is_empty()
231 )?;
232
233 Ok(())
234 }
235
236 #[gtest]
237 fn unmatched_start() -> Result<()> {
238 let v = run("// tidy-alphabetical-start\nuse a;");
239 verify_eq!(v.len(), 1)?;
240 verify_true!(v[0].message.contains("unmatched"))?;
241
242 Ok(())
243 }
244
245 #[gtest]
246 fn nested_regions_are_opaque() -> Result<()> {
247 verify_true!(
248 run("// tidy-alphabetical-start\n\
249 use alpha;\n\
250 // tidy-alphabetical-start\n\
251 use zebra;\n\
252 use aardvark;\n\
253 // tidy-alphabetical-end\n\
254 use beta;\n\
255 // tidy-alphabetical-end")
256 .is_empty()
257 )?;
258
259 Ok(())
260 }
261
262 #[gtest]
263 fn no_region_no_violations() -> Result<()> {
264 verify_true!(run("use zebra;\nuse alpha;").is_empty())?;
265
266 Ok(())
267 }
268
269 #[gtest]
270 fn fix_sorts_region() -> Result<()> {
271 let source = "// tidy-alphabetical-start\n\
272 use gamma;\n\
273 use alpha;\n\
274 use beta;\n\
275 // tidy-alphabetical-end";
276 let fixed = crate::apply_line_fixes(source, check_alphabetical, fix_alphabetical);
277 let remaining = run(&fixed);
278 verify_true!(remaining.is_empty())?;
279 let lines: Vec<&str> = fixed.lines().collect();
280 verify_eq!(lines[1].trim(), "use alpha;")?;
281 verify_eq!(lines[2].trim(), "use beta;")?;
282 verify_eq!(lines[3].trim(), "use gamma;")?;
283
284 Ok(())
285 }
286
287 #[gtest]
288 fn fix_preserves_comments() -> Result<()> {
289 let source = "// tidy-alphabetical-start\n\
290 use gamma;\n\
291 // a comment\n\
292 use alpha;\n\
293 // tidy-alphabetical-end";
294 let fixed = crate::apply_line_fixes(source, check_alphabetical, fix_alphabetical);
295 let remaining = run(&fixed);
296 verify_true!(remaining.is_empty())?;
297
298 Ok(())
299 }
300});