Skip to main content

f00_core/
sort.rs

1use crate::entry::{Entry, EntryKind};
2use crate::options::{ListOptions, SortBy};
3
4/// Case-insensitive name comparison.
5///
6/// In GNU mode, compare raw byte/string order without stripping leading dots.
7pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
8    cmp_name_with_mode(a, b, false)
9}
10
11pub fn cmp_name_with_mode(a: &str, b: &str, gnu: bool) -> std::cmp::Ordering {
12    if gnu {
13        // Approximate LC_COLLATE=C: byte-wise, case-sensitive.
14        return a.cmp(b);
15    }
16    // Friendly default: case-insensitive, interleave hidden files.
17    let a_key = a.trim_start_matches('.').to_ascii_lowercase();
18    let b_key = b.trim_start_matches('.').to_ascii_lowercase();
19    match a_key.cmp(&b_key) {
20        std::cmp::Ordering::Equal => a.cmp(b),
21        other => other,
22    }
23}
24
25/// strverscmp-like natural / version comparison (`ls -v`).
26///
27/// Digit runs compare numerically (with leading-zero special-case similar to glibc);
28/// non-digit runs compare byte-wise. `~` sorts before every other character and
29/// before end-of-string (glibc `strverscmp`).
30pub fn cmp_version(a: &str, b: &str) -> std::cmp::Ordering {
31    use std::cmp::Ordering;
32    let ab = a.as_bytes();
33    let bb = b.as_bytes();
34    let mut i = 0usize;
35    let mut j = 0usize;
36
37    while i < ab.len() || j < bb.len() {
38        // glibc: `~` sorts before every other character *and* before end-of-string.
39        if i >= ab.len() {
40            return if bb[j] == b'~' {
41                Ordering::Greater
42            } else {
43                Ordering::Less
44            };
45        }
46        if j >= bb.len() {
47            return if ab[i] == b'~' {
48                Ordering::Less
49            } else {
50                Ordering::Greater
51            };
52        }
53
54        let a_digit = ab[i].is_ascii_digit();
55        let b_digit = bb[j].is_ascii_digit();
56
57        if a_digit && b_digit {
58            // Leading zeros: all-zero prefix is "fractional" in glibc strverscmp.
59            let a_zeros = count_leading(ab, i, b'0');
60            let b_zeros = count_leading(bb, j, b'0');
61            let i0 = i + a_zeros;
62            let j0 = j + b_zeros;
63            let a_digits = count_digits(ab, i0);
64            let b_digits = count_digits(bb, j0);
65
66            // Compare digit values first by length then lexicographically.
67            match a_digits.cmp(&b_digits) {
68                Ordering::Equal => {
69                    let acmp = ab[i0..i0 + a_digits].cmp(&bb[j0..j0 + b_digits]);
70                    if acmp != Ordering::Equal {
71                        return acmp;
72                    }
73                    // Equal numbers: more leading zeros → less (glibc).
74                    if a_zeros != b_zeros {
75                        return b_zeros.cmp(&a_zeros);
76                    }
77                }
78                ord => return ord,
79            }
80            i = i0 + a_digits;
81            j = j0 + b_digits;
82            continue;
83        }
84
85        if a_digit != b_digit {
86            return cmp_nondigit_byte(ab[i], bb[j]);
87        }
88
89        // Both non-digit: compare until digit or end.
90        while i < ab.len() && j < bb.len() && !ab[i].is_ascii_digit() && !bb[j].is_ascii_digit() {
91            match cmp_nondigit_byte(ab[i], bb[j]) {
92                Ordering::Equal => {
93                    i += 1;
94                    j += 1;
95                }
96                ord => return ord,
97            }
98        }
99        if i < ab.len() && j < bb.len() {
100            // One hit a digit boundary.
101            if ab[i].is_ascii_digit() != bb[j].is_ascii_digit() {
102                return cmp_nondigit_byte(ab[i], bb[j]);
103            }
104        }
105    }
106    Ordering::Equal
107}
108
109/// glibc `strverscmp`: `~` sorts before every other character, including end-of-string.
110fn cmp_nondigit_byte(a: u8, b: u8) -> std::cmp::Ordering {
111    use std::cmp::Ordering;
112    if a == b {
113        return Ordering::Equal;
114    }
115    if a == b'~' {
116        return Ordering::Less;
117    }
118    if b == b'~' {
119        return Ordering::Greater;
120    }
121    a.cmp(&b)
122}
123
124fn count_leading(bytes: &[u8], start: usize, ch: u8) -> usize {
125    let mut n = 0;
126    while start + n < bytes.len() && bytes[start + n] == ch {
127        n += 1;
128    }
129    n
130}
131
132fn count_digits(bytes: &[u8], start: usize) -> usize {
133    let mut n = 0;
134    while start + n < bytes.len() && bytes[start + n].is_ascii_digit() {
135        n += 1;
136    }
137    n
138}
139
140pub(crate) fn cmp_entry(a: &Entry, b: &Entry, opts: &ListOptions) -> std::cmp::Ordering {
141    use std::cmp::Ordering;
142
143    if opts.dirs_first {
144        let a_dir = a.kind == EntryKind::Directory;
145        let b_dir = b.kind == EntryKind::Directory;
146        match (a_dir, b_dir) {
147            (true, false) => return Ordering::Less,
148            (false, true) => return Ordering::Greater,
149            _ => {}
150        }
151    }
152
153    let name_cmp = || cmp_name_with_mode(&a.name, &b.name, opts.gnu_mode);
154
155    let primary = match opts.sort_by {
156        SortBy::None => Ordering::Equal,
157        SortBy::Name => name_cmp(),
158        // GNU `-S`: largest first.
159        SortBy::Size => b.size.cmp(&a.size).then_with(name_cmp),
160        SortBy::Time => {
161            let ta = a.time_for(opts.time_field);
162            let tb = b.time_for(opts.time_field);
163            // Newest first.
164            tb.cmp(&ta).then_with(name_cmp)
165        }
166        SortBy::Extension => {
167            let ea = a.extension().unwrap_or("");
168            let eb = b.extension().unwrap_or("");
169            if opts.gnu_mode {
170                ea.cmp(eb).then_with(name_cmp)
171            } else {
172                ea.to_ascii_lowercase()
173                    .cmp(&eb.to_ascii_lowercase())
174                    .then_with(name_cmp)
175            }
176        }
177        SortBy::Version => cmp_version(&a.name, &b.name).then_with(name_cmp),
178        // GNU `--sort=width`: shortest display width first, then name.
179        SortBy::Width => display_width(&a.name)
180            .cmp(&display_width(&b.name))
181            .then_with(name_cmp),
182    };
183
184    if opts.reverse {
185        primary.reverse()
186    } else {
187        primary
188    }
189}
190
191/// Approximate printed width for `--sort=width` (ASCII = 1; wide CJK ≈ 2).
192fn display_width(s: &str) -> usize {
193    s.chars()
194        .map(|c| {
195            let u = c as u32;
196            if c == '\0' {
197                0
198            } else if u < 0x1100 {
199                1
200            } else if (0x1100..=0x115f).contains(&u)
201                || u == 0x2329
202                || u == 0x232a
203                || (0x2e80..=0xa4cf).contains(&u)
204                || (0xac00..=0xd7a3).contains(&u)
205                || (0xf900..=0xfaff).contains(&u)
206                || (0xfe10..=0xfe19).contains(&u)
207                || (0xfe30..=0xfe6f).contains(&u)
208                || (0xff00..=0xff60).contains(&u)
209                || (0xffe0..=0xffe6).contains(&u)
210            {
211                2
212            } else {
213                1
214            }
215        })
216        .sum()
217}
218
219/// Sort entries according to options. Directory headers keep relative order.
220pub fn sort_entries(entries: &mut [Entry], opts: &ListOptions) {
221    if opts.sort_by == SortBy::None {
222        if opts.reverse {
223            entries.reverse();
224        }
225        return;
226    }
227
228    entries.sort_by(|a, b| match (a.is_dir_header, b.is_dir_header) {
229        (true, false) => std::cmp::Ordering::Less,
230        (false, true) => std::cmp::Ordering::Greater,
231        _ => cmp_entry(a, b, opts),
232    });
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::entry::{Entry, EntryKind, GitStatus};
239    use std::path::PathBuf;
240    use std::time::{Duration, SystemTime};
241
242    fn entry(name: &str, kind: EntryKind, size: u64, secs: Option<u64>) -> Entry {
243        Entry {
244            path: PathBuf::from(name),
245            name: name.to_string(),
246            kind,
247            size,
248            modified: secs.map(|s| SystemTime::UNIX_EPOCH + Duration::from_secs(s)),
249            created: None,
250            accessed: None,
251            changed: secs.map(|s| SystemTime::UNIX_EPOCH + Duration::from_secs(s)),
252            mode: 0,
253            readonly: false,
254            symlink_target: None,
255            depth: 0,
256            git_status: GitStatus::Clean,
257            is_dir_header: false,
258            nlink: 1,
259            uid: 0,
260            gid: 0,
261            inode: 0,
262            blocks: 0,
263            owner: "u".into(),
264            group: "g".into(),
265            author: "u".into(),
266            context: String::new(),
267        }
268    }
269
270    #[test]
271    fn version_tilde_before_digits() {
272        use std::cmp::Ordering;
273        assert_eq!(cmp_version("file~", "file1"), Ordering::Less);
274        assert_eq!(cmp_version("file1", "file2"), Ordering::Less);
275        assert_eq!(cmp_version("file2", "file10"), Ordering::Less);
276        assert_eq!(cmp_version("file~", "file10"), Ordering::Less);
277    }
278
279    #[test]
280    fn width_sort_shorter_first() {
281        let opts = ListOptions {
282            sort_by: SortBy::Width,
283            ..Default::default()
284        };
285        let a = entry("a", EntryKind::File, 0, None);
286        let b = entry("bbbb", EntryKind::File, 0, None);
287        assert_eq!(cmp_entry(&a, &b, &opts), std::cmp::Ordering::Less);
288    }
289}