Skip to main content

vsort/
lib.rs

1use core::cmp::{Ordering, PartialOrd};
2
3/// sort will sort the given array in place using GNU version sort.
4/// # Examples
5/// ```
6/// use vsort::sort;
7///
8/// fn main() {
9///     let mut file_names = vec![
10///        "a.txt",
11///        "b 1.txt",
12///        "b 10.txt",
13///        "b 11.txt",
14///        "b 5.txt",
15///        "Ssm.txt",
16///     ];
17///
18///     sort( & mut file_names);
19///     assert_eq!(
20///         file_names,
21///         vec!["Ssm.txt", "a.txt", "b 1.txt", "b 5.txt", "b 10.txt", "b 11.txt"]
22///     );
23/// }
24/// ```
25pub fn sort(arr: &mut [&str]) {
26    arr.sort_by(|a, b| compare(a, b));
27}
28
29/// compare implements GNU version sort.
30/// # Examples
31/// ```
32/// use vsort::compare;
33///
34/// fn main() {
35///     let mut file_names = vec![
36///         "a.txt",
37///         "b 1.txt",
38///         "b 10.txt",
39///         "b 11.txt",
40///         "b 5.txt",
41///         "Ssm.txt",
42///     ];
43///
44///     // Pass to sort_by
45///     file_names.sort_by(|a, b| compare(a, b));
46///     assert_eq!(
47///         file_names,
48///         vec!["Ssm.txt", "a.txt", "b 1.txt", "b 5.txt", "b 10.txt", "b 11.txt"]
49///     );
50/// }
51/// ```
52pub fn compare(a: &str, b: &str) -> Ordering {
53    // Let's shadow the inputs for easy reference.
54    let mut a = a;
55    let mut b = b;
56
57    // The spec says that the following have special priority and sort before
58    // all other strings, in the listed order: ("", ".", "..").
59    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L532-L569
60    if let Some(cmp) = match (a, b) {
61        ("", "") | (".", ".") | ("..", "..") => Some(Ordering::Equal),
62        ("", _) => Some(Ordering::Less),
63        (_, "") => Some(Ordering::Greater),
64        (".", _) => Some(Ordering::Less),
65        (_, ".") => Some(Ordering::Greater),
66        ("..", _) => Some(Ordering::Less),
67        (_, "..") => Some(Ordering::Greater),
68        _ => None,
69    } {
70        return cmp;
71    };
72
73    // Hidden files get priority. If both files are hidden then we remove the leading period
74    // and compare.
75    match (a.starts_with('.'), b.starts_with('.')) {
76        (true, false) => return Ordering::Less,
77        (false, true) => return Ordering::Greater,
78        (false, false) => {}
79        (true, true) => {
80            a = if a.len() == 1 { "" } else { &a[1..] };
81            b = if b.len() == 1 { "" } else { &b[1..] };
82        }
83    }
84
85    // Compare without the file extensions
86    let cmp = sequence_cmp(split_extension(a).0, split_extension(b).0);
87    if cmp != Ordering::Equal {
88        return cmp;
89    }
90    // Compare the original strings with the file extensions
91    let cmp = sequence_cmp(a, b);
92    if cmp != Ordering::Equal {
93        return cmp;
94    }
95    // At this point the file extensions are the same, so we compare the full strings.
96    // this helps with cases like a0001 and a1 so that they have a consistent ordering.
97    a.cmp(b)
98}
99
100/// sequence_cmp extracts non-digit and digit sequences from the two strings and compares the
101/// sequences until an ordering is determined.
102fn sequence_cmp(a: &str, b: &str) -> Ordering {
103    let mut a_str = a;
104    let mut b_str = b;
105    loop {
106        let (a_non_digit_part, remaining_a) = non_digit_seq(a_str);
107        let (b_non_digit_part, remaining_b) = non_digit_seq(b_str);
108        let cmp = compare_non_digit_seq(a_non_digit_part, b_non_digit_part);
109        if cmp != Ordering::Equal {
110            return cmp;
111        }
112        let (a_digit_part, remaining_a) = digit_seq(remaining_a);
113        let (b_digit_part, remaining_b) = digit_seq(remaining_b);
114
115        // According to the docs, a missing numerical part also counts as zero.
116        let a_digits = a_digit_part.parse::<u64>().unwrap_or_default();
117        let b_digits = b_digit_part.parse::<u64>().unwrap_or_default();
118        let cmp = a_digits.cmp(&b_digits);
119        if cmp != Ordering::Equal {
120            return cmp;
121        }
122
123        a_str = remaining_a;
124        b_str = remaining_b;
125
126        // If any or both strings have been exhausted we can determine the ordering.
127        if a_str.is_empty() && b_str.is_empty() {
128            return Ordering::Equal;
129        }
130    }
131}
132
133/*
134fn split_extension(s: &str) -> (&str, &str) {
135    // According to GNU sort, an extension is defined as a dot, followed by an
136    // ASCII letter or tilde, followed by zero or more ASCII letters, digits,
137    // or tildes; all repeated zero or more times, and ending at string end.
138    // The regex is from https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L584-L591
139    let re = Regex::new(r"(\.[A-Za-z~][A-Za-z0-9~]*)*$").unwrap();
140
141    re.find(s).map_or((s, ""), |m| {
142        let (a, b) = s.split_at(m.start());
143        (a, b)
144    })
145}
146 */
147
148fn split_extension(s: &str) -> (&str, &str) {
149    // According to GNU sort, an extension is defined as a dot, followed by an
150    // ASCII letter or tilde, followed by zero or more ASCII letters, digits,
151    // or tildes; all repeated zero or more times, and ending at string end.
152    // The regex is from https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L584-L591
153    let mut split_ind: Option<usize> = None;
154    let mut last_char: Option<char> = None;
155    for (i, c) in s.char_indices().rev() {
156        // If we have found a period
157        if c == '.' {
158            match last_char {
159                // We found a period as our last character. Exit with no extension
160                None => return (s, ""),
161                Some(prev_char) => {
162                    // If the previous character wasn't alphanumeric this isn't a valid
163                    if prev_char.is_ascii_alphabetic() || prev_char == '~' {
164                        split_ind = Some(i);
165                    } else {
166                        break;
167                    }
168                }
169            }
170        } else if !(c.is_ascii_alphanumeric() || c == '~') {
171            break;
172        }
173        // Update the last char for inspection
174        last_char = Some(c);
175    }
176
177    split_ind.map_or((s, ""), |ind| s.split_at(ind))
178}
179
180#[derive(Eq)]
181struct VersionSortChar(Option<u8>);
182
183impl From<Option<u8>> for VersionSortChar {
184    fn from(c: Option<u8>) -> Self {
185        Self(c)
186    }
187}
188
189impl PartialOrd for VersionSortChar {
190    // Based on https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi
191    // For non-digit characters, we apply the following rules:
192    //   ~(tilde) comes before all other strings, even the empty string.
193    //   ASCII letters sort before other bytes.
194    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
195        match (self.0, other.0) {
196            (None, None) => Some(Ordering::Equal),
197            (Some(a), None) => {
198                if a == b'~' {
199                    Some(Ordering::Less)
200                } else {
201                    Some(Ordering::Greater)
202                }
203            }
204            (None, Some(b)) => {
205                if b == b'~' {
206                    Some(Ordering::Greater)
207                } else {
208                    Some(Ordering::Less)
209                }
210            }
211            (Some(a), Some(b)) => {
212                if a == b {
213                    return Some(Ordering::Equal);
214                }
215                if a == b'~' {
216                    return Some(Ordering::Less);
217                }
218                if b == b'~' {
219                    return Some(Ordering::Greater);
220                }
221                match (a.is_ascii_alphabetic(), b.is_ascii_alphabetic()) {
222                    // ASCII letters sort before other bytes. If they are both ASCII
223                    // or both are not ASCII sort normally.
224                    (true, true) | (false, false) => Some(a.cmp(&b)),
225                    (true, false) => Some(Ordering::Less),
226                    (false, true) => Some(Ordering::Greater),
227                }
228            }
229        }
230    }
231}
232
233impl PartialEq for VersionSortChar {
234    fn eq(&self, other: &Self) -> bool {
235        self.0 == other.0
236    }
237}
238
239fn compare_non_digit_seq(a: &str, b: &str) -> Ordering {
240    let mut a_bytes = a.bytes();
241    let mut b_bytes = b.bytes();
242    loop {
243        let a_byte = a_bytes.next();
244        let b_byte = b_bytes.next();
245        if a_byte.is_none() && b_byte.is_none() {
246            return Ordering::Equal;
247        }
248        let cmp = VersionSortChar::from(a_byte)
249            .partial_cmp(&VersionSortChar::from(b_byte))
250            .unwrap();
251        if cmp == Ordering::Equal {
252            continue;
253        }
254        return cmp;
255    }
256}
257
258fn non_digit_seq(a: &str) -> (&str, &str) {
259    a.bytes()
260        .enumerate()
261        .find(|(_, c)| c.is_ascii_digit())
262        .map_or((a, ""), |(index, _)| a.split_at(index))
263}
264
265fn digit_seq(a: &str) -> (&str, &str) {
266    a.bytes()
267        .enumerate()
268        .find(|(_, c)| !c.is_ascii_digit())
269        .map_or((a, ""), |(index, _)| a.split_at(index))
270}
271
272#[cfg(test)]
273mod test {
274    use test_case::test_case;
275
276    use super::*;
277
278    #[test_case(vec!["", "~"], vec!["", "~"]; "in sorted order")]
279    #[test_case(vec!["~", ""], vec!["", "~"]; "in reversed order")]
280    fn test_empty_string_vs_tilde(original: Vec<&str>, expected: Vec<&str>) {
281        let mut list = original;
282        sort(&mut list);
283        assert_eq!(list, expected);
284    }
285
286    #[test]
287    fn test_non_digit_sorting() {
288        let mut list = vec!["aaa", "aa", "aab", "aa&", "aa_", "aa~", "a"];
289        list.sort_by(|a, b| compare_non_digit_seq(a, b));
290
291        assert_eq!(
292            list,
293            vec![
294                "a", // Absolute shortest comes first
295                "aa~", "aa", // Tilde comes before empty string
296                "aaa", "aab", "aa&", "aa_", // ASCII letters come before other bytes
297            ]
298        );
299    }
300
301    #[test]
302    fn test_non_digit_seq() {
303        let a = "file_1.txt";
304        let (seq, remainder) = non_digit_seq(a);
305        assert_eq!(seq, "file_");
306        assert_eq!(remainder, "1.txt");
307
308        let (seq, remainder) = non_digit_seq(&a[5..]);
309        assert_eq!(seq, "");
310        assert_eq!(remainder, "1.txt");
311
312        let (seq, remainder) = non_digit_seq(&a[6..]);
313        assert_eq!(seq, ".txt");
314        assert_eq!(remainder, "");
315    }
316
317    #[test]
318    fn test_unusual_test_case() {
319        let mut list = vec![
320            "a.txt", "b 1.txt", "b 10.txt", "b 11.txt", "b 5.txt", "Ssm.txt",
321        ];
322        sort(&mut list);
323
324        assert_eq!(
325            list,
326            vec!["Ssm.txt", "a.txt", "b 1.txt", "b 5.txt", "b 10.txt", "b 11.txt"]
327        );
328    }
329
330    // This tests that the implementation can handle characters that are longer than a single byte.
331    #[test_case(
332      vec!["αβγ2.txt", "αβγ1.txt", "1αβγ.txt", "2αβγ.txt"],
333      vec!["1αβγ.txt", "2αβγ.txt", "αβγ1.txt", "αβγ2.txt"];
334      "test_with_non_ascii"
335    )]
336    fn test_with_non_utf8(original: Vec<&str>, expected: Vec<&str>) {
337        let mut list = original;
338        sort(&mut list);
339        assert_eq!(list, expected);
340    }
341
342    #[test]
343    fn test_missing_number_part() {
344        let mut original_list = vec!["file.txt", "file0.txt"];
345        sort(&mut original_list);
346
347        assert_eq!(original_list, vec!["file0.txt", "file.txt"]);
348
349        let mut original_list = vec!["file0.txt", "file.txt"];
350        sort(&mut original_list);
351
352        assert_eq!(original_list, vec!["file0.txt", "file.txt"]);
353    }
354
355    // Coreutils Tests
356    // These tests are lifted from https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi
357    // They are used in the spec to clarify some sorting rules. They seemed useful enough to add here.
358
359    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L265-L285
360    #[test_case(
361      vec!["8.10", "8.5", "8.1", "8.01", "8.010", "8.100", "8.49"],
362      vec!["8.01", "8.1", "8.5", "8.010", "8.10", "8.49", "8.100"];
363      "sort with numbers"
364    )]
365    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L316-L335
366    #[test_case(
367      vec!["1.0_src.tar.gz", "1.0.5_src.tar.gz"],
368      vec!["1.0.5_src.tar.gz", "1.0_src.tar.gz"];
369      "period is before underscore"
370    )]
371    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L353-L363
372    #[test_case(
373      vec!["3.0/", "3.0.5"],
374      vec!["3.0.5", "3.0/"];
375      "period is before forward slash"
376    )]
377    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L372-L379
378    #[test_case(
379      vec!["a%", "az"],
380      vec!["az", "a%"];
381      "letters before non-letters"
382    )]
383    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L400-L413
384    #[test_case(
385      vec!["1", "1%", "1.2", "1~", "~"],
386      vec!["~", "1~", "1", "1%", "1.2"];
387      "tilde before all others strings"
388    )]
389    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L451-L456
390    #[test_case(
391      vec!["aa", "az", "a%", "aα"],
392      vec!["aa", "az", "a%", "aα"];
393      "sort ignores locale"
394    )]
395    // https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L551-L560
396    #[test_case(
397      vec!["a", "b", ".", "c", "..", ".d20", ".d3"],
398      vec![".", "..", ".d3", ".d20", "a", "b", "c"];
399      "special directories and hidden files are sorted first"
400    )]
401    fn test_basic_tests(original: Vec<&str>, expected: Vec<&str>) {
402        let mut list = original;
403        sort(&mut list);
404        assert_eq!(list, expected);
405    }
406
407    // Examples from https://github.com/coreutils/coreutils/blob/master/doc/sort-version.texi#L608-L634
408    #[test_case("hello-8.txt", ("hello-8", ".txt"); "basic")]
409    #[test_case("hello-8.2.txt", ("hello-8.2", ".txt"); "with major and minor")]
410    #[test_case("hello-8.0.12.tar.gz", ("hello-8.0.12", ".tar.gz"); "with extension")]
411    #[test_case("hello-8.2", ("hello-8.2", ""); "without extension")]
412    #[test_case("hello.foobar65", ("hello", ".foobar65"); "with long extension")]
413    #[test_case(
414      "gcc-c++-10.8.12-0.7rc2.fc9.tar.bz2",
415      ("gcc-c++-10.8.12-0.7rc2", ".fc9.tar.bz2");
416      "with multiple extensions"
417    )]
418    #[test_case(".autom4te.cfg", ("", ".autom4te.cfg"); "empty name with extension")]
419    #[test_case("a.#$%", ("a.#$%", ""); "no extension present")]
420    #[test_case("a.#$%.txt", ("a.#$%", ".txt"); "extension stops at non-alphanumeric characters")]
421    fn test_split_extension(input: &str, split: (&str, &str)) {
422        assert_eq!(split_extension(input), split);
423    }
424
425    // This list is pulled from
426    // https://github.com/coreutils/gnulib/blob/master/tests/test-filevercmp.c#L26-L102
427    #[test]
428    fn test_long_sorted_list() {
429        let expected = vec![
430            "",
431            ".",
432            "..",
433            ".0",
434            ".9",
435            ".A",
436            ".Z",
437            ".a~",
438            ".a",
439            ".b~",
440            ".b",
441            ".z",
442            ".zz~",
443            ".zz",
444            ".zz.~1~",
445            ".zz.0",
446            ".\u{1}",
447            ".\u{1}.txt",
448            ".\u{1}x",
449            ".\u{1}x\u{1}",
450            ".\u{1}.0",
451            "0",
452            "9",
453            "A",
454            "Z",
455            "a~",
456            "a",
457            "a.b~",
458            "a.b",
459            "a.bc~",
460            "a.bc",
461            "a+",
462            "a.",
463            "a..a",
464            "a.+",
465            "b~",
466            "b",
467            "gcc-c++-10.fc9.tar.gz",
468            "gcc-c++-10.fc9.tar.gz.~1~",
469            "gcc-c++-10.fc9.tar.gz.~2~",
470            "gcc-c++-10.8.12-0.7rc2.fc9.tar.bz2",
471            "gcc-c++-10.8.12-0.7rc2.fc9.tar.bz2.~1~",
472            "glibc-2-0.1.beta1.fc10.rpm",
473            "glibc-common-5-0.2.beta2.fc9.ebuild",
474            "glibc-common-5-0.2b.deb",
475            "glibc-common-11b.ebuild",
476            "glibc-common-11-0.6rc2.ebuild",
477            "libstdc++-0.5.8.11-0.7rc2.fc10.tar.gz",
478            "libstdc++-4a.fc8.tar.gz",
479            "libstdc++-4.10.4.20040204svn.rpm",
480            "libstdc++-devel-3.fc8.ebuild",
481            "libstdc++-devel-3a.fc9.tar.gz",
482            "libstdc++-devel-8.fc8.deb",
483            "libstdc++-devel-8.6.2-0.4b.fc8",
484            "nss_ldap-1-0.2b.fc9.tar.bz2",
485            "nss_ldap-1-0.6rc2.fc8.tar.gz",
486            "nss_ldap-1.0-0.1a.tar.gz",
487            "nss_ldap-10beta1.fc8.tar.gz",
488            "nss_ldap-10.11.8.6.20040204cvs.fc10.ebuild",
489            "z",
490            "zz~",
491            "zz",
492            "zz.~1~",
493            "zz.0",
494            "zz.0.txt",
495            "\u{1}",
496            "\u{1}.txt",
497            "\u{1}x",
498            "\u{1}x\u{1}",
499            "\u{1}.0",
500            "#\u{1}.b#",
501            "#.b#",
502        ];
503        let mut list = expected.clone();
504        list.reverse();
505        assert_ne!(list, expected);
506        sort(&mut list);
507        assert_eq!(list, expected);
508    }
509
510    // These tests are lifted from
511    // https://github.com/coreutils/gnulib/blob/master/tests/test-filevercmp.c
512    #[test_case(vec!["a", "a0", "a0000"]; "zeros are the same as empty string")]
513    #[test_case(vec!["a\u{1}c-27.txt", "a\u{1}c-027.txt", "a\u{1}c-00000000000000000000000000000000000000000000000000000027.txt",]; "non-ascii")]
514    #[test_case(vec![".a\u{1}c-27.txt", ".a\u{1}c-027.txt", ".a\u{1}c-00000000000000000000000000000000000000000000000000000027.txt",]; "non-ascii with leading period")]
515    #[test_case(vec!["a\u{1}c-", "a\u{1}c-0", "a\u{1}c-00",]; "non-ascii without extension")]
516    #[test_case(vec![".a\u{1}c-", ".a\u{1}c-0", ".a\u{1}c-00",]; "non-ascii without extension and leading period")]
517    #[test_case(vec!["a\u{1}c-0.txt", "a\u{1}c-00.txt"]; "non-ascii with trailing zeros")]
518    #[test_case(vec![".a\u{1}c-1\u{1}.txt", ".a\u{1}c-001\u{1}.txt"]; "non-ascii with leading zeros before a number")]
519    fn test_strings_cmp_equal(list: Vec<&str>) {
520        let end = list.len();
521        for i in 0..end {
522            for j in (i + 1)..end {
523                assert_eq!(sequence_cmp(list[i], list[j]), Ordering::Equal);
524            }
525        }
526    }
527}