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}