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}