Skip to main content

stern4rust/finding/model/
import_path.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5// The imports whose order rustfmt decides rather than the alphabet.
6//
7// rustfmt sorts `self`, `super` and `crate` ahead of every other path. It also
8// treats case as significant -- and which direction it leans depends on the
9// style edition, which is the part worth measuring rather than guessing:
10//
11// |                                       | 2021         | 2024         |
12// |---------------------------------------|--------------|--------------|
13// | `Bbb::gamma` against `zzz::last`       | sorts last   | sorts first  |
14// | `serde_json::Value` against `from_str` | `from_str`   | `Value`      |
15//
16// The two editions disagree with each other, so no single alphabet can be
17// right for both, and this crate cannot know which one the crate under
18// inspection compiles with. That is the argument for standing down rather than
19// picking a side: declining to judge is correct under either edition, while
20// demanding an order would be wrong under one of them.
21//
22// None of it matches a plain alphabetic sort, and unlike every other
23// disagreement this tool could have with a formatter, this one has no
24// resolution: `cargo fmt` writes one order, the rule would demand another, and
25// stage 1 runs the formatter first. A file caught between the two cannot be
26// fixed by hand at all -- each run undoes the last.
27//
28// One shape the two editions do agree on is an extended path, and it is handled
29// below for that reason rather than this one.
30//
31// So the structure rule stands down on exactly those pairs. Everything else in
32// the import list is still ordered, because among segments of the same case
33// rustfmt's comparator and the alphabet agree.
34//
35// The case rule is pairwise rather than a property of one import, and that
36// distinction is the whole of it. `use serde_json::Value` and
37// `use serde_json::from_str` share a first segment and part company at the
38// second, where one is uppercase and one is not. Neither path is remarkable on
39// its own; only the pair is.
40pub struct ImportPath;
41
42impl ImportPath {
43    // Whether rustfmt, rather than the alphabet, decides this pair's order.
44    pub fn decides_order(previous: &str, item: &str) -> bool {
45        Self::is_specially_ordered(previous)
46            || Self::is_specially_ordered(item)
47            || Self::diverges_by_case(previous, item)
48            || Self::one_extends_the_other(previous, item)
49    }
50
51    // Whether the pair is already in order, compared the way rustfmt compares:
52    // segment by segment, on the path alone.
53    //
54    // The line as written is the wrong thing to sort on, and in two ways. It
55    // ends in `;` (0x3B), which loses to any digit or letter, so
56    // `aaa::select` read as belonging *after* `aaa::select4` -- one path being
57    // a prefix of the other at the last segment is enough. And it may open with
58    // `pub `, whose `p` beats the `u` of `use`, so every re-export read as
59    // belonging above every plain import whatever it named.
60    //
61    // Both were found in `etheram-raft-embassy`, on `select` beside `select4`,
62    // `Either` beside `Either4`, and a `pub use` among ordinary ones.
63    // `cargo fmt --check` was clean on all of them, so this rule was demanding
64    // an order the formatter would immediately undo -- the deadlock the
65    // stand-downs above exist to prevent, reached through the comparison
66    // instead.
67    pub fn is_ordered(previous: &str, item: &str) -> bool {
68        Self::segments(previous) <= Self::segments(item)
69    }
70
71    pub fn is_specially_ordered(import: &str) -> bool {
72        let first = Self::first_segment(import);
73        matches!(first, "self" | "super" | "crate")
74            || first.chars().next().is_some_and(char::is_uppercase)
75    }
76
77    // Where two paths first differ, and whether the segments there are of
78    // different case. That is the one place the two comparators can disagree:
79    // before it the paths are identical, and after it nothing is compared.
80    fn diverges_by_case(previous: &str, item: &str) -> bool {
81        Self::segments(previous)
82            .into_iter()
83            .zip(Self::segments(item))
84            .find(|(left, right)| left != right)
85            .is_some_and(|(left, right)| !Self::share_a_case_shape(left, right))
86    }
87
88    // Two segments compare the same way under both comparators only when they
89    // are of the same shape, and an initial capital is not enough to say so.
90    // `WAL_V2_MAGIC` and `WalRecord` both open with one and the editions still
91    // disagree: measured, 2021 puts `WalRecord` first and 2024 `WAL_V2_MAGIC`,
92    // the same split already recorded for `Value` against `from_str`. Reading
93    // only the first character called that pair same-case and demanded the
94    // alphabet, which is a file `cargo fmt` rewrites on every run -- found in
95    // `etheram-ibft`, where it cost sixteen offences no edit could clear and
96    // both import rules had to be stood down to keep stage 1 green.
97    //
98    // Shape is the initial and whether the segment is all capitals, because
99    // those are the two axes the disagreement runs along. Segments sharing both
100    // -- `ALPHA_TWO` against `ZETA_ONE`, `Alpha` against `Zeta`, `alpha`
101    // against `zeta` -- were measured to sort identically under 2021, 2024 and
102    // a plain sort, so those are still judged.
103    fn share_a_case_shape(left: &str, right: &str) -> bool {
104        Self::is_uppercase(left) == Self::is_uppercase(right)
105            && Self::is_all_capitals(left) == Self::is_all_capitals(right)
106    }
107
108    // Capitals and no lowercase. Digits and underscores decide nothing, so `V2`
109    // reads as capitals and `v2` does not.
110    fn is_all_capitals(segment: &str) -> bool {
111        segment.chars().any(char::is_uppercase) && !segment.chars().any(char::is_lowercase)
112    }
113
114    // One path continues the other: `alloc::vec` beside `alloc::vec::Vec`.
115    //
116    // `diverges_by_case` cannot see this, because it looks for the first pair of
117    // segments that differ and there is no such pair -- the difference is
118    // between a segment and nothing at all. Compared as written the shorter line
119    // ends in `;` (59) where the longer carries on with `::` (58), so a plain
120    // sort demands the longer path first no matter what follows, while rustfmt
121    // demands the shorter. Every extension is a disagreement, so every extension
122    // stands down.
123    //
124    // A rename is not an extension. `bbb as ccc` is one segment rather than
125    // `bbb` followed by another, so the pair falls through to the alphabet,
126    // which is what rustfmt does with it too.
127    fn one_extends_the_other(previous: &str, item: &str) -> bool {
128        let (left, right) = (Self::segments(previous), Self::segments(item));
129        let shared = left.len().min(right.len());
130        left.len() != right.len() && left[..shared] == right[..shared]
131    }
132
133    // The first segment, not a prefix: a crate genuinely named `crateful` sorts
134    // alphabetically like anything else.
135    fn first_segment(import: &str) -> &str {
136        Self::segments(import).first().copied().unwrap_or_default()
137    }
138
139    fn is_uppercase(segment: &str) -> bool {
140        segment.chars().next().is_some_and(char::is_uppercase)
141    }
142
143    fn segments(import: &str) -> Vec<&str> {
144        import
145            .trim()
146            .trim_start_matches("pub ")
147            .trim_start_matches("use ")
148            .trim_start()
149            .trim_end_matches(';')
150            .trim_start_matches("::")
151            .split("::")
152            .map(str::trim)
153            .collect()
154    }
155}