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            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});