Skip to main content

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

1#[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});