Skip to main content

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//! Numeric sequences are parsed as `u64` values, so each sequence must fit within `u64`.
13//!
14//! Empty lines and lines starting (ignoring spaces) with `//` or `#` (except those starting with
15//! `#!`) are considered comments are are sorted together with the next line (but do not affect
16//! sorting).
17//!
18//! If the following lines have higher indentation we effectively join them with the current line
19//! before comparing it. If the next line with the same indentation starts (ignoring spaces) with
20//! a closing delimiter (`)`, `[`, `}`) it is joined as well.
21//!
22//! E.g.
23//!
24//! ```rust,ignore ilustrative example for sorting mentioning non-existent functions
25//! foo(a,
26//!     b);
27//! bar(
28//!   a,
29//!   b
30//! );
31//! // are treated for sorting purposes as
32//! foo(a, b);
33//! bar(a, b);
34//! ```
35
36use std::cmp::Ordering;
37use std::fs;
38use std::io::{Seek, Write};
39use std::iter::Peekable;
40use std::ops::{Range, RangeBounds};
41use std::path::Path;
42
43use crate::diagnostics::{CheckId, RunningCheck, TidyCtx};
44use crate::walk::{filter_dirs, walk};
45
46#[cfg(test)]
47mod tests;
48
49fn indentation(line: &str) -> usize {
50    line.find(|c| c != ' ').unwrap_or(0)
51}
52
53fn is_close_bracket(c: char) -> bool {
54    matches!(c, ')' | ']' | '}')
55}
56
57fn is_empty_or_comment(line: &&str) -> bool {
58    let trimmed_line = line.trim_start_matches(' ').trim_end_matches('\n');
59
60    trimmed_line.is_empty()
61        || trimmed_line.starts_with("//")
62        || (trimmed_line.starts_with('#') && !trimmed_line.starts_with("#!"))
63}
64
65const START_MARKER: &str = "tidy-alphabetical-start";
66const END_MARKER: &str = "tidy-alphabetical-end";
67
68/// Given contents of a section that is enclosed between [`START_MARKER`] and [`END_MARKER`], sorts
69/// them according to the rules described at the top of the module.
70fn sort_section(section: &str) -> String {
71    /// A sortable item
72    struct Item {
73        /// Full contents including comments and whitespace
74        full: String,
75        /// Trimmed contents for sorting
76        trimmed: String,
77    }
78
79    let mut items = Vec::new();
80    let mut lines = section.split_inclusive('\n').peekable();
81
82    let end_comments = loop {
83        let mut full = String::new();
84        let mut trimmed = String::new();
85
86        while let Some(comment) = lines.next_if(is_empty_or_comment) {
87            full.push_str(comment);
88        }
89
90        let Some(line) = lines.next() else {
91            // remember comments at the end of a block
92            break full;
93        };
94
95        let mut push = |line| {
96            full.push_str(line);
97            trimmed.push_str(line.trim_start_matches(' ').trim_end_matches('\n'))
98        };
99
100        push(line);
101
102        let indent = indentation(line);
103        let mut multiline = false;
104
105        // If the item is split between multiple lines...
106        while let Some(more_indented) =
107            lines.next_if(|&line: &&_| indent < indentation(line) || line == "\n")
108        {
109            multiline = true;
110            push(more_indented);
111        }
112
113        if multiline
114            && let Some(indented) =
115                // Only append next indented line if it looks like a closing bracket.
116                // Otherwise we incorrectly merge code like this (can be seen in
117                // compiler/rustc_session/src/options.rs):
118                //
119                // force_unwind_tables: Option<bool> = (None, parse_opt_bool, [TRACKED],
120                //     "force use of unwind tables"),
121                // incremental: Option<String> = (None, parse_opt_string, [UNTRACKED],
122                //     "enable incremental compilation"),
123                lines.next_if(|l| {
124                    indentation(l) == indent
125                        && l.trim_start_matches(' ').starts_with(is_close_bracket)
126                })
127        {
128            push(indented);
129        }
130
131        items.push(Item { full, trimmed });
132    };
133
134    items.sort_by(|a, b| version_sort(&a.trimmed, &b.trimmed));
135    items.into_iter().map(|l| l.full).chain([end_comments]).collect()
136}
137
138fn check_lines<'a>(path: &Path, content: &'a str, tidy_ctx: &TidyCtx, check: &mut RunningCheck) {
139    let mut offset = 0;
140
141    loop {
142        let rest = &content[offset..];
143        let start = rest.find(START_MARKER);
144        let end = rest.find(END_MARKER);
145
146        match (start, end) {
147            // error handling
148
149            // end before start
150            (Some(start), Some(end)) if end < start => {
151                check.error(format!(
152                    "{path}:{line_number} found `{END_MARKER}` expecting `{START_MARKER}`",
153                    path = path.display(),
154                    line_number = content[..offset + end].lines().count(),
155                ));
156                break;
157            }
158
159            // end without a start
160            (None, Some(end)) => {
161                check.error(format!(
162                    "{path}:{line_number} found `{END_MARKER}` expecting `{START_MARKER}`",
163                    path = path.display(),
164                    line_number = content[..offset + end].lines().count(),
165                ));
166                break;
167            }
168
169            // start without an end
170            (Some(start), None) => {
171                check.error(format!(
172                    "{path}:{line_number} `{START_MARKER}` without a matching `{END_MARKER}`",
173                    path = path.display(),
174                    line_number = content[..offset + start].lines().count(),
175                ));
176                break;
177            }
178
179            // a second start in between start/end pair
180            (Some(start), Some(end))
181                if rest[start + START_MARKER.len()..end].contains(START_MARKER) =>
182            {
183                check.error(format!(
184                    "{path}:{line_number} found `{START_MARKER}` expecting `{END_MARKER}`",
185                    path = path.display(),
186                    line_number = content[..offset
187                        + sub_find(rest, start + START_MARKER.len()..end, START_MARKER)
188                            .unwrap()
189                            .start]
190                        .lines()
191                        .count()
192                ));
193                break;
194            }
195
196            // happy happy path :3
197            (Some(start), Some(end)) => {
198                assert!(start <= end);
199
200                // "...␤// tidy-alphabetical-start␤...␤// tidy-alphabetical-end␤..."
201                //                  start_nl_end --^  ^-- end_nl_start          ^-- end_nl_end
202
203                // Position after the newline after start marker
204                let start_nl_end = sub_find(rest, start + START_MARKER.len().., "\n").unwrap().end;
205
206                // Position before the new line before the end marker
207                let end_nl_start = rest[..end].rfind('\n').unwrap();
208
209                // Position after the newline after end marker
210                let end_nl_end = sub_find(rest, end + END_MARKER.len().., "\n")
211                    .map(|r| r.end)
212                    .unwrap_or(content.len() - offset);
213
214                // This can happen when start and end tags are on the same line...
215                // annoying, but then there's nothing to sort, just skip.
216                if end_nl_start < start_nl_end {
217                    offset += end_nl_end;
218                    continue;
219                }
220
221                let section = &rest[start_nl_end..=end_nl_start];
222                let sorted = sort_section(section);
223
224                // oh nyooo :(
225                if sorted != section {
226                    if !tidy_ctx.is_bless_enabled() {
227                        let pre = &content[..offset + start_nl_end];
228                        assert_eq!(pre.chars().rev().next(), Some('\n'));
229                        // start_nl_end spans right after the ␤, so it gets ignored by `lines()`,
230                        // but we do want to count it! so we add 1 to the result.
231                        let base_line_number = pre.lines().count() + 1;
232                        let line_offset = sorted
233                            .lines()
234                            .zip(section.lines())
235                            .enumerate()
236                            .find(|(_, (a, b))| a != b)
237                            .unwrap()
238                            .0;
239                        let line_number = base_line_number + line_offset;
240
241                        check.error(format!(
242                            "{path}:{line_number}: line not in alphabetical order (tip: use --bless to sort this list)",
243                            path = path.display(),
244                        ));
245                    } else {
246                        // Use atomic rename as to not corrupt the file upon crashes/ctrl+c
247                        let mut tempfile =
248                            tempfile::Builder::new().tempfile_in(path.parent().unwrap()).unwrap();
249
250                        fs::copy(path, tempfile.path()).unwrap();
251
252                        tempfile
253                            .as_file_mut()
254                            .seek(std::io::SeekFrom::Start((offset + start_nl_end) as u64))
255                            .unwrap();
256                        tempfile.as_file_mut().write_all(sorted.as_bytes()).unwrap();
257
258                        tempfile.persist(path).unwrap();
259                    }
260                }
261
262                // Start the next search after the end section
263                offset += end_nl_end;
264            }
265
266            // No more alphabetical lists, yay :3
267            (None, None) => break,
268        }
269    }
270}
271
272pub fn check(path: &Path, tidy_ctx: TidyCtx) {
273    let mut check = tidy_ctx.start_check(CheckId::new("alphabetical").path(path));
274
275    let skip = |path: &_, _is_dir| {
276        filter_dirs(path)
277            || path.ends_with("tidy/src/alphabetical.rs")
278            || path.ends_with("tidy/src/alphabetical/tests.rs")
279    };
280
281    walk(path, skip, &mut |entry, content| {
282        check_lines(entry.path(), content, &tidy_ctx, &mut check)
283    });
284}
285
286fn consume_numeric_prefix<I: Iterator<Item = char>>(it: &mut Peekable<I>) -> String {
287    let mut result = String::new();
288
289    while let Some(&c) = it.peek() {
290        if !c.is_numeric() {
291            break;
292        }
293
294        result.push(c);
295        it.next();
296    }
297
298    result
299}
300
301// A sorting function that is case-sensitive, and sorts sequences of digits by their numeric value,
302// so that `9` sorts before `12`.
303fn version_sort(a: &str, b: &str) -> Ordering {
304    let mut it1 = a.chars().peekable();
305    let mut it2 = b.chars().peekable();
306
307    while let (Some(x), Some(y)) = (it1.peek(), it2.peek()) {
308        match (x.is_numeric(), y.is_numeric()) {
309            (true, true) => {
310                let num1: String = consume_numeric_prefix(it1.by_ref());
311                let num2: String = consume_numeric_prefix(it2.by_ref());
312
313                let int1: u64 = num1.parse().unwrap();
314                let int2: u64 = num2.parse().unwrap();
315
316                // Compare strings when the numeric value is equal to handle "00" versus "0".
317                match int1.cmp(&int2).then_with(|| num1.cmp(&num2)) {
318                    Ordering::Equal => continue,
319                    different => return different,
320                }
321            }
322            (false, false) => match x.cmp(y) {
323                Ordering::Equal => {
324                    it1.next();
325                    it2.next();
326                    continue;
327                }
328                different => return different,
329            },
330            (false, true) | (true, false) => {
331                return x.cmp(y);
332            }
333        }
334    }
335
336    it1.next().cmp(&it2.next())
337}
338
339/// Finds `pat` in `s[range]` and returns a range such that `s[ret] == pat`.
340fn sub_find(s: &str, range: impl RangeBounds<usize>, pat: &str) -> Option<Range<usize>> {
341    s[(range.start_bound().cloned(), range.end_bound().cloned())]
342        .find(pat)
343        .map(|pos| {
344            pos + match range.start_bound().cloned() {
345                std::ops::Bound::Included(x) => x,
346                std::ops::Bound::Excluded(x) => x + 1,
347                std::ops::Bound::Unbounded => 0,
348            }
349        })
350        .map(|pos| pos..pos + pat.len())
351}