Skip to main content

matchsorter/
options.rs

1//! Configuration options and ranked-item types for the match-sorting algorithm.
2//!
3//! [`MatchSorterOptions`] controls global behavior such as diacritics handling,
4//! key extraction, threshold filtering, and sort customization.
5//!
6//! [`RankedItem`] annotates an item with its ranking metadata, used during
7//! sorting and exposed to custom sort functions.
8
9use std::borrow::Cow;
10use std::cmp::Ordering;
11use std::fmt;
12
13use crate::key::Key;
14use crate::ranking::Ranking;
15
16/// Type alias for a custom tiebreaker sort closure used in [`MatchSorterOptions`].
17///
18/// Given two ranked items, returns their relative ordering for tie-breaking
19/// when rank and key index are equal.
20type BaseSortFn<T> = Box<dyn Fn(&RankedItem<T>, &RankedItem<T>) -> Ordering>;
21
22/// Type alias for a complete sort-override closure used in [`MatchSorterOptions`].
23///
24/// Receives the filtered ranked items and returns them in the desired final order,
25/// completely replacing the default three-level sort.
26type SorterFn<T> = Box<dyn Fn(Vec<RankedItem<T>>) -> Vec<RankedItem<T>>>;
27
28/// An item annotated with its ranking information.
29///
30/// Produced during the ranking phase of the match-sorting pipeline and
31/// passed to sorting functions (both the default three-level comparator and
32/// custom `base_sort` / `sorter` overrides).
33///
34/// # Type Parameters
35///
36/// * `'a` - Lifetime of the reference to the original item in the input slice.
37/// * `T` - The item type being ranked.
38///
39/// # Examples
40///
41/// ```
42/// use std::borrow::Cow;
43/// use matchsorter::{RankedItem, Ranking};
44///
45/// let item = "hello".to_owned();
46/// let ranked = RankedItem {
47///     item: &item,
48///     index: 0,
49///     rank: Ranking::CaseSensitiveEqual,
50///     ranked_value: Cow::Borrowed("hello"),
51///     key_index: 0,
52///     key_threshold: None,
53/// };
54/// assert_eq!(ranked.rank, Ranking::CaseSensitiveEqual);
55/// assert_eq!(*ranked.item, "hello");
56/// ```
57#[derive(Debug, Clone, PartialEq)]
58pub struct RankedItem<'a, T> {
59    /// Reference to the original item in the input slice.
60    pub item: &'a T,
61
62    /// Original index of the item in the input slice, used for stable
63    /// sort tie-breaking.
64    pub index: usize,
65
66    /// The ranking score representing how well the item matched the query.
67    pub rank: Ranking,
68
69    /// The string value (from one of the item's keys) that produced the
70    /// best match against the query. Borrowed in no-keys mode (zero-copy
71    /// from the input slice) and owned in keys mode.
72    pub ranked_value: Cow<'a, str>,
73
74    /// Index of the winning key-value pair in the flattened key-values list.
75    /// Lower values indicate keys declared earlier in the keys array.
76    pub key_index: usize,
77
78    /// Per-key threshold override from the winning key, or `None` if the
79    /// key uses the global threshold.
80    pub key_threshold: Option<Ranking>,
81}
82
83/// Global options that control match-sorting behavior.
84///
85/// Generic over `T` to allow type-safe key extractors via [`Key<T>`].
86///
87/// # Defaults
88///
89/// All fields default to their most common usage:
90/// - `keys`: empty (no-keys mode; items must be string-like)
91/// - `threshold`: `Ranking::Matches(1.0)` (include fuzzy matches and above)
92/// - `keep_diacritics`: `false` (diacritics are stripped before comparison)
93/// - `base_sort`: `None` (uses default alphabetical tiebreaker)
94/// - `sorter`: `None` (uses default three-level sort)
95///
96/// Because `base_sort` and `sorter` hold trait objects (`Box<dyn Fn>`),
97/// `MatchSorterOptions<T>` cannot derive `Clone`, `PartialEq`, or `Default`.
98/// A manual [`Default`] implementation is provided.
99///
100/// # Examples
101///
102/// ```
103/// use matchsorter::MatchSorterOptions;
104///
105/// // Default options: strip diacritics, no keys, lowest threshold
106/// let opts = MatchSorterOptions::<String>::default();
107/// assert!(!opts.keep_diacritics);
108/// assert!(opts.keys.is_empty());
109/// assert!(opts.base_sort.is_none());
110/// assert!(opts.sorter.is_none());
111/// ```
112pub struct MatchSorterOptions<T> {
113    /// Key extractors for pulling matchable string values from items.
114    ///
115    /// When empty, items are ranked directly via [`AsMatchStr`](crate::no_keys::AsMatchStr)
116    /// (no-keys mode). When non-empty, each key's extractor is called on
117    /// every item to produce candidate strings for ranking.
118    pub keys: Vec<Key<T>>,
119
120    /// Minimum ranking tier required to include an item in results.
121    ///
122    /// Items whose best ranking falls below this threshold are filtered out.
123    /// Defaults to `Ranking::Matches(1.0)`, the lowest valid fuzzy match
124    /// score, meaning all matching items (including fuzzy) are included.
125    pub threshold: Ranking,
126
127    /// When `true`, diacritics (accents, combining marks) are preserved during
128    /// comparison. When `false` (default), diacritics are stripped so that
129    /// e.g. "cafe" matches "caf\u{00e9}".
130    pub keep_diacritics: bool,
131
132    /// Custom tiebreaker sort function.
133    ///
134    /// Called when two items have identical rank and key index during the
135    /// default three-level sort. When `None`, the default alphabetical
136    /// comparison of `ranked_value` is used.
137    pub base_sort: Option<BaseSortFn<T>>,
138
139    /// Complete sort override.
140    ///
141    /// When `Some`, replaces the entire default sorting pipeline. The
142    /// closure receives the filtered `Vec<RankedItem<T>>` and must return
143    /// the items in the desired final order. When `None`, the default
144    /// three-level sort (rank descending, key_index ascending, base_sort
145    /// tiebreaker) is used.
146    pub sorter: Option<SorterFn<T>>,
147}
148
149impl<T> Default for MatchSorterOptions<T> {
150    /// Returns default options matching the JS `match-sorter` library defaults.
151    ///
152    /// - `keys`: empty (no-keys mode)
153    /// - `threshold`: `Ranking::Matches(1.0)` (include all fuzzy matches)
154    /// - `keep_diacritics`: `false`
155    /// - `base_sort`: `None`
156    /// - `sorter`: `None`
157    fn default() -> Self {
158        Self {
159            keys: Vec::new(),
160            threshold: Ranking::Matches(1.0),
161            keep_diacritics: false,
162            base_sort: None,
163            sorter: None,
164        }
165    }
166}
167
168// Manual `Debug` implementation because `Box<dyn Fn>` does not implement
169// `Debug`. We print the function fields as `Some(<fn>)` or `None`.
170impl<T> fmt::Debug for MatchSorterOptions<T> {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.debug_struct("MatchSorterOptions")
173            .field("keys", &format_args!("[{} key(s)]", self.keys.len()))
174            .field("threshold", &self.threshold)
175            .field("keep_diacritics", &self.keep_diacritics)
176            .field(
177                "base_sort",
178                if self.base_sort.is_some() {
179                    &"Some(<fn>)" as &dyn fmt::Debug
180                } else {
181                    &"None" as &dyn fmt::Debug
182                },
183            )
184            .field(
185                "sorter",
186                if self.sorter.is_some() {
187                    &"Some(<fn>)" as &dyn fmt::Debug
188                } else {
189                    &"None" as &dyn fmt::Debug
190                },
191            )
192            .finish()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn default_keep_diacritics_is_false() {
202        let opts = MatchSorterOptions::<String>::default();
203        assert!(!opts.keep_diacritics);
204    }
205
206    #[test]
207    fn default_threshold_is_matches() {
208        let opts = MatchSorterOptions::<String>::default();
209        assert_eq!(opts.threshold, Ranking::Matches(1.0));
210    }
211
212    #[test]
213    fn default_keys_is_empty() {
214        let opts = MatchSorterOptions::<String>::default();
215        assert!(opts.keys.is_empty());
216    }
217
218    #[test]
219    fn default_base_sort_is_none() {
220        let opts = MatchSorterOptions::<String>::default();
221        assert!(opts.base_sort.is_none());
222    }
223
224    #[test]
225    fn default_sorter_is_none() {
226        let opts = MatchSorterOptions::<String>::default();
227        assert!(opts.sorter.is_none());
228    }
229
230    #[test]
231    fn debug_formatting() {
232        let opts = MatchSorterOptions::<String>::default();
233        let debug_str = format!("{opts:?}");
234        assert!(debug_str.contains("keep_diacritics"));
235        assert!(debug_str.contains("threshold"));
236        assert!(debug_str.contains("MatchSorterOptions"));
237    }
238
239    #[test]
240    fn debug_formatting_with_base_sort() {
241        let opts = MatchSorterOptions::<String> {
242            base_sort: Some(Box::new(|_a, _b| Ordering::Equal)),
243            ..Default::default()
244        };
245        let debug_str = format!("{opts:?}");
246        assert!(debug_str.contains("Some(<fn>)"));
247    }
248
249    #[test]
250    fn ranked_item_construction() {
251        let item = "hello".to_owned();
252        let ranked = RankedItem {
253            item: &item,
254            index: 0,
255            rank: Ranking::CaseSensitiveEqual,
256            ranked_value: Cow::Borrowed("hello"),
257            key_index: 0,
258            key_threshold: None,
259        };
260        assert_eq!(ranked.rank, Ranking::CaseSensitiveEqual);
261        assert_eq!(ranked.ranked_value, "hello");
262        assert_eq!(ranked.index, 0);
263        assert_eq!(ranked.key_index, 0);
264        assert_eq!(ranked.key_threshold, None);
265        assert_eq!(*ranked.item, "hello");
266    }
267
268    #[test]
269    fn ranked_item_with_threshold() {
270        let item = 42u32;
271        let ranked = RankedItem {
272            item: &item,
273            index: 3,
274            rank: Ranking::Contains,
275            ranked_value: Cow::Borrowed("forty-two"),
276            key_index: 1,
277            key_threshold: Some(Ranking::StartsWith),
278        };
279        assert_eq!(ranked.key_threshold, Some(Ranking::StartsWith));
280        assert_eq!(*ranked.item, 42);
281    }
282
283    #[test]
284    fn ranked_item_debug() {
285        let item = "test".to_owned();
286        let ranked = RankedItem {
287            item: &item,
288            index: 0,
289            rank: Ranking::Acronym,
290            ranked_value: Cow::Borrowed("test"),
291            key_index: 0,
292            key_threshold: None,
293        };
294        let debug_str = format!("{ranked:?}");
295        assert!(debug_str.contains("Acronym"));
296        assert!(debug_str.contains("test"));
297    }
298
299    #[test]
300    fn ranked_item_clone() {
301        let item = "world".to_owned();
302        let ranked = RankedItem {
303            item: &item,
304            index: 1,
305            rank: Ranking::StartsWith,
306            ranked_value: Cow::Borrowed("world"),
307            key_index: 2,
308            key_threshold: Some(Ranking::Contains),
309        };
310        let cloned = ranked.clone();
311        assert_eq!(ranked, cloned);
312    }
313
314    #[test]
315    fn ranked_item_partial_eq() {
316        let item = "a".to_owned();
317        let a = RankedItem {
318            item: &item,
319            index: 0,
320            rank: Ranking::Equal,
321            ranked_value: Cow::Borrowed("a"),
322            key_index: 0,
323            key_threshold: None,
324        };
325        let b = RankedItem {
326            item: &item,
327            index: 0,
328            rank: Ranking::Equal,
329            ranked_value: Cow::Borrowed("a"),
330            key_index: 0,
331            key_threshold: None,
332        };
333        assert_eq!(a, b);
334    }
335
336    #[test]
337    fn ranked_item_partial_eq_different_rank() {
338        let item = "a".to_owned();
339        let a = RankedItem {
340            item: &item,
341            index: 0,
342            rank: Ranking::Equal,
343            ranked_value: Cow::Borrowed("a"),
344            key_index: 0,
345            key_threshold: None,
346        };
347        let b = RankedItem {
348            item: &item,
349            index: 0,
350            rank: Ranking::Contains,
351            ranked_value: Cow::Borrowed("a"),
352            key_index: 0,
353            key_threshold: None,
354        };
355        assert_ne!(a, b);
356    }
357}