Skip to main content

zsh/
tolerant_sort.rs

1//! Rust-only utility (NOT a port — lives outside `src/ported/` by design).
2//!
3//! C's completion and parameter-sort code sorts with `qsort`, whose comparator
4//! contract is merely "return <0/0/>0"; glibc/BSD `qsort` never inspects the
5//! comparator for consistency, so a non-transitive comparator just yields an
6//! unspecified-but-valid permutation. Rust's `slice::sort_by` /
7//! `sort_unstable_by` instead PANIC ("comparison function does not correctly
8//! implement a total order") the moment they detect inconsistency.
9//!
10//! The natural / numeric ordering in `zstrcmp` (`NUMERICGLOBSORT`, the `(n)`
11//! subscript flag, `matchcmp`, `eltpcmp`, `cd_sort`) is a well-known
12//! non-transitive comparator, so `sort_by` crashed the shell with a `<TAB>`
13//! that completed a large match set. [`qsort_tolerant`] reproduces `qsort`'s
14//! tolerance: it never crashes regardless of comparator consistency.
15
16use std::cmp::Ordering;
17
18use crate::metafied_key::MetafiedOperand;
19use crate::ported::zle::comp_h::Cmatch;
20use crate::ported::zle::compcore::matchcmp;
21
22/// Bottom-up **stable** merge sort that TOLERATES a comparator which is not a
23/// strict weak ordering — the stand-in for C's `qsort`. O(n log n), ties keep
24/// their input order, never panics on an inconsistent `cmp`.
25///
26/// The sort runs over indices so a non-transitive `cmp` can only affect the
27/// final order — it can never cause an out-of-bounds access or a non-terminating
28/// loop.
29///
30/// !!! MEASURED DIVERGENCE FROM `qsort(3)` — this sort is STABLE, `qsort` is
31/// not. `man 3 qsort` (Darwin): "The algorithms implemented by qsort(),
32/// qsort_r(), and heapsort() are not stable; that is, if two members compare
33/// as equal, their order in the sorted array is undefined." Wherever the C
34/// code lets a `qsort` TIE decide something observable, this function answers
35/// "input order" and the C answers "whatever the platform libc's quicksort
36/// partitioning left first". Reproducing the C answer is not portable: the
37/// same zsh binary gives a different answer on a different libc, so there is
38/// no single order to port to.
39///
40/// One such site is measured: `makearray`'s CGF_NOSORT duplicate pass
41/// (`compcore.c:3299-3303`, ported at `compcore.rs` `makearray`) sorts a COPY
42/// of the match array only to mark the LATER member of each equal pair
43/// `CMF_DELETE`, and the surviving member keeps its slot in the DISPLAY array.
44/// When the same match set is `compadd`ed twice into one group (`_git`'s
45/// `__git_recent_commits` does, once per completer pass), the tie picks which
46/// copy dies, so the listing order is the tie order. `git show <TAB><TAB>`
47/// lists 20 commits in two ascending runs under zsh/Darwin and in plain
48/// insertion order here; the sets and the descriptions are identical.
49pub fn qsort_tolerant<T: Clone, F>(v: &mut [T], mut cmp: F)
50where
51    F: FnMut(&T, &T) -> Ordering,
52{
53    let n = v.len();
54    if n < 2 {
55        return;
56    }
57    let mut idx: Vec<usize> = (0..n).collect();
58    let mut tmp: Vec<usize> = vec![0usize; n];
59    let mut width = 1;
60    while width < n {
61        let mut i = 0;
62        while i < n {
63            let left = i;
64            let mid = (i + width).min(n);
65            let right = (i + 2 * width).min(n);
66            let (mut l, mut r, mut k) = (left, mid, left);
67            while l < mid && r < right {
68                // Take from the left run unless it strictly follows the right
69                // run — keeps the sort stable.
70                if cmp(&v[idx[l]], &v[idx[r]]) == Ordering::Greater {
71                    tmp[k] = idx[r];
72                    r += 1;
73                } else {
74                    tmp[k] = idx[l];
75                    l += 1;
76                }
77                k += 1;
78            }
79            while l < mid {
80                tmp[k] = idx[l];
81                l += 1;
82                k += 1;
83            }
84            while r < right {
85                tmp[k] = idx[r];
86                r += 1;
87                k += 1;
88            }
89            i += 2 * width;
90        }
91        std::mem::swap(&mut idx, &mut tmp);
92        width *= 2;
93    }
94    // Apply the permutation IN PLACE (`v[i] = old_v[idx[i]]`) by walking each
95    // cycle with `swap`. C's `qsort` permutes fixed-size records and allocates
96    // nothing; the previous code materialised the result with one deep
97    // `clone()` per element and then copied it back, so a 46765-match
98    // completion sort deep-copied 46765 `Cmatch` records (a dozen owned
99    // `String`s each) for nothing. `swap` moves the records bit-for-bit and
100    // touches no heap.
101    // `tmp` is finished as merge scratch — reuse it as the cycle-visited
102    // marker so the in-place pass allocates nothing at all.
103    let visited = &mut tmp;
104    visited.iter_mut().for_each(|s| *s = 0);
105    for start in 0..n {
106        if visited[start] != 0 {
107            continue;
108        }
109        let mut i = start;
110        loop {
111            visited[i] = 1;
112            let j = idx[i];
113            // `j == i` can only happen at a fixed point; `j == start` closes
114            // the cycle. Either way this cycle is done.
115            if j == start || j == i {
116                break;
117            }
118            v.swap(i, j);
119            i = j;
120        }
121    }
122}
123
124/// The `zstrcmp` operands `matchcmp` (`compcore.c:3173`) reads from one
125/// match: `(*m)->str` (c:3181-3182) and `(*m)->disp` (c:3191-3192), in the
126/// metafied, NUL-terminated form C's `Cmatch` already stores them in.
127/// Built once per match per sort by [`qsort_matches`].
128pub struct MatchSortKey {
129    pub str: MetafiedOperand,
130    pub disp: Option<MetafiedOperand>,
131}
132
133impl MatchSortKey {
134    pub fn new(m: &Cmatch) -> Self {
135        MatchSortKey {
136            str: MetafiedOperand::new(m.str.as_deref().unwrap_or("")),
137            disp: m.disp.as_deref().map(MetafiedOperand::new),
138        }
139    }
140}
141
142/// `qsort(rp, n, sizeof(Cmatch), matchcmp)` — `makearray`'s two sorts
143/// (`compcore.c:3262` and c:3301) — over `ord`, an index permutation into
144/// `src` (the port's stand-in for C's array of `Cmatch` pointers).
145///
146/// The comparator's operands are prepared once per match up front: C's
147/// `Cmatch` holds them ready-made, and preparing them inside the comparator
148/// repeated that work on each of the O(n log n) comparisons. Measured on
149/// `arch <TAB>` (47058 command names; `_description`'s `_setup` reads
150/// `$compstate[nmatches]` four times, and each read re-sorts the group the
151/// way C's `permmatches` does) the per-comparison preparation was 61% of the
152/// completion.
153pub fn qsort_matches(ord: &mut [usize], src: &[Cmatch]) {
154    let keys: Vec<MatchSortKey> = src.iter().map(MatchSortKey::new).collect();
155    qsort_tolerant(ord, |a: &usize, b: &usize| {
156        matchcmp(&src[*a], &keys[*a], &src[*b], &keys[*b])
157    });
158}
159
160#[cfg(test)]
161mod tests {
162    use super::qsort_tolerant;
163    use std::cmp::Ordering;
164
165    #[test]
166    fn sorts_like_a_total_order() {
167        let mut v = vec![3, 1, 4, 1, 5, 9, 2, 6];
168        qsort_tolerant(&mut v, |a, b| a.cmp(b));
169        assert_eq!(v, vec![1, 1, 2, 3, 4, 5, 6, 9]);
170    }
171
172    #[test]
173    fn is_stable() {
174        // Sort by first field only; equal keys must keep input order.
175        let mut v = vec![(1, 'a'), (1, 'b'), (0, 'c'), (1, 'd')];
176        qsort_tolerant(&mut v, |a, b| a.0.cmp(&b.0));
177        assert_eq!(v, vec![(0, 'c'), (1, 'a'), (1, 'b'), (1, 'd')]);
178    }
179
180    #[test]
181    fn tolerates_non_transitive_comparator_without_panicking() {
182        // A deliberately inconsistent comparator (rock-paper-scissors on mod 3)
183        // that would make std sort_by panic. We only require: no panic, and a
184        // valid permutation of the input.
185        let mut v: Vec<i32> = (0..30).collect();
186        qsort_tolerant(&mut v, |a, b| match (a.rem_euclid(3), b.rem_euclid(3)) {
187            (0, 1) | (1, 2) | (2, 0) => Ordering::Less,
188            (1, 0) | (2, 1) | (0, 2) => Ordering::Greater,
189            _ => a.cmp(b),
190        });
191        let mut check = v.clone();
192        check.sort();
193        assert_eq!(check, (0..30).collect::<Vec<_>>());
194    }
195
196    /// `makearray`'s `qsort(..., matchcmp)` over a completion-sized match
197    /// set. `arch <TAB>` sorts ~47k command names four times (one per
198    /// `$compstate[nmatches]` read that finds new matches), and with the
199    /// comparator re-deriving its operands per call that took ~8s per sort in
200    /// the debug build and the cell never drew within the harness's 10s. The
201    /// bound is far above the prepared-operand cost and far below the old one.
202    /// The order must also be what pairwise `matchcmp` says it is.
203    #[test]
204    fn qsort_matches_sorts_a_large_match_set_in_bounded_time() {
205        use super::{qsort_matches, MatchSortKey};
206        use crate::ported::zle::comp_h::Cmatch;
207        use crate::ported::zle::compcore::{matchcmp, MATCHORDER};
208        let _g = crate::test_util::global_state_lock();
209        MATCHORDER.store(0, std::sync::atomic::Ordering::Relaxed);
210        // Deterministic scramble of 40000 distinct command-like names.
211        let n = 40_000usize;
212        let src: Vec<Cmatch> = (0..n)
213            .map(|i| {
214                let k = (i * 7919) % n;
215                let mut m = Cmatch::default();
216                m.str = Some(format!("cmd-{}{}", ["git", "Zip", "ls", "x_"][k % 4], k));
217                m
218            })
219            .collect();
220        let mut ord: Vec<usize> = (0..n).collect();
221        let t = std::time::Instant::now();
222        qsort_matches(&mut ord, &src);
223        let took = t.elapsed();
224        assert!(
225            took < std::time::Duration::from_secs(4),
226            "sorting {n} matches took {took:?}"
227        );
228        let mut seen = vec![false; n];
229        for &i in &ord {
230            assert!(!seen[i], "index {i} twice");
231            seen[i] = true;
232        }
233        for w in ord.windows(2) {
234            let (a, b) = (&src[w[0]], &src[w[1]]);
235            assert_ne!(
236                matchcmp(a, &MatchSortKey::new(a), b, &MatchSortKey::new(b)),
237                Ordering::Greater,
238                "{:?} sorted before {:?}",
239                a.str,
240                b.str
241            );
242        }
243    }
244}