tidy/
alphabetical.rs

1//! Checks that a list of items is in alphabetical order
2//!
3//! Use the following marker in the code:
4//! ```rust
5//! // tidy-alphabetical-start
6//! fn aaa() {}
7//! fn eee() {}
8//! fn z() {}
9//! // tidy-alphabetical-end
10//! ```
11//!
12//! The following lines are ignored:
13//! - Empty lines
14//! - Lines that are indented with more or less spaces than the first line
15//! - Lines starting with `//`, `#` (except those starting with `#!`), `)`, `]`, `}` if the comment
16//!   has the same indentation as the first line
17//! - Lines starting with a closing delimiter (`)`, `[`, `}`) are ignored.
18//!
19//! If a line ends with an opening delimiter, we effectively join the following line to it before
20//! checking it. E.g. `foo(\nbar)` is treated like `foo(bar)`.
21
22use std::cmp::Ordering;
23use std::fmt::Display;
24use std::iter::Peekable;
25use std::path::Path;
26
27use crate::diagnostics::{CheckId, DiagCtx, RunningCheck};
28use crate::walk::{filter_dirs, walk};
29
30#[cfg(test)]
31mod tests;
32
33fn indentation(line: &str) -> usize {
34    line.find(|c| c != ' ').unwrap_or(0)
35}
36
37fn is_close_bracket(c: char) -> bool {
38    matches!(c, ')' | ']' | '}')
39}
40
41const START_MARKER: &str = "tidy-alphabetical-start";
42const END_MARKER: &str = "tidy-alphabetical-end";
43
44fn check_section<'a>(
45    file: impl Display,
46    lines: impl Iterator<Item = (usize, &'a str)>,
47    check: &mut RunningCheck,
48) {
49    let mut prev_line = String::new();
50    let mut first_indent = None;
51    let mut in_split_line = None;
52
53    for (idx, line) in lines {
54        if line.is_empty() {
55            continue;
56        }
57
58        if line.contains(START_MARKER) {
59            check.error(format!(
60                "{file}:{} found `{START_MARKER}` expecting `{END_MARKER}`",
61                idx + 1
62            ));
63            return;
64        }
65
66        if line.contains(END_MARKER) {
67            return;
68        }
69
70        let indent = first_indent.unwrap_or_else(|| {
71            let indent = indentation(line);
72            first_indent = Some(indent);
73            indent
74        });
75
76        let line = if let Some(prev_split_line) = in_split_line {
77            // Join the split lines.
78            in_split_line = None;
79            format!("{prev_split_line}{}", line.trim_start())
80        } else {
81            line.to_string()
82        };
83
84        if indentation(&line) != indent {
85            continue;
86        }
87
88        let trimmed_line = line.trim_start_matches(' ');
89
90        if trimmed_line.starts_with("//")
91            || (trimmed_line.starts_with('#') && !trimmed_line.starts_with("#!"))
92            || trimmed_line.starts_with(is_close_bracket)
93        {
94            continue;
95        }
96
97        if line.trim_end().ends_with('(') {
98            in_split_line = Some(line);
99            continue;
100        }
101
102        let prev_line_trimmed_lowercase = prev_line.trim_start_matches(' ');
103
104        if version_sort(trimmed_line, prev_line_trimmed_lowercase).is_lt() {
105            check.error(format!("{file}:{}: line not in alphabetical order", idx + 1));
106        }
107
108        prev_line = line;
109    }
110
111    check.error(format!("{file}: reached end of file expecting `{END_MARKER}`"));
112}
113
114fn check_lines<'a>(
115    file: &impl Display,
116    mut lines: impl Iterator<Item = (usize, &'a str)>,
117    check: &mut RunningCheck,
118) {
119    while let Some((idx, line)) = lines.next() {
120        if line.contains(END_MARKER) {
121            check.error(format!(
122                "{file}:{} found `{END_MARKER}` expecting `{START_MARKER}`",
123                idx + 1
124            ));
125        }
126
127        if line.contains(START_MARKER) {
128            check_section(file, &mut lines, check);
129        }
130    }
131}
132
133pub fn check(path: &Path, diag_ctx: DiagCtx) {
134    let mut check = diag_ctx.start_check(CheckId::new("alphabetical").path(path));
135
136    let skip =
137        |path: &_, _is_dir| filter_dirs(path) || path.ends_with("tidy/src/alphabetical/tests.rs");
138
139    walk(path, skip, &mut |entry, contents| {
140        let file = &entry.path().display();
141        let lines = contents.lines().enumerate();
142        check_lines(file, lines, &mut check)
143    });
144}
145
146fn consume_numeric_prefix<I: Iterator<Item = char>>(it: &mut Peekable<I>) -> String {
147    let mut result = String::new();
148
149    while let Some(&c) = it.peek() {
150        if !c.is_numeric() {
151            break;
152        }
153
154        result.push(c);
155        it.next();
156    }
157
158    result
159}
160
161// A sorting function that is case-sensitive, and sorts sequences of digits by their numeric value,
162// so that `9` sorts before `12`.
163fn version_sort(a: &str, b: &str) -> Ordering {
164    let mut it1 = a.chars().peekable();
165    let mut it2 = b.chars().peekable();
166
167    while let (Some(x), Some(y)) = (it1.peek(), it2.peek()) {
168        match (x.is_numeric(), y.is_numeric()) {
169            (true, true) => {
170                let num1: String = consume_numeric_prefix(it1.by_ref());
171                let num2: String = consume_numeric_prefix(it2.by_ref());
172
173                let int1: u64 = num1.parse().unwrap();
174                let int2: u64 = num2.parse().unwrap();
175
176                // Compare strings when the numeric value is equal to handle "00" versus "0".
177                match int1.cmp(&int2).then_with(|| num1.cmp(&num2)) {
178                    Ordering::Equal => continue,
179                    different => return different,
180                }
181            }
182            (false, false) => match x.cmp(y) {
183                Ordering::Equal => {
184                    it1.next();
185                    it2.next();
186                    continue;
187                }
188                different => return different,
189            },
190            (false, true) | (true, false) => {
191                return x.cmp(y);
192            }
193        }
194    }
195
196    it1.next().cmp(&it2.next())
197}