use matchsorter::{AsMatchStr, Key, MatchSorterOptions, RankedItem, Ranking, match_sorter};
#[derive(Debug, PartialEq)]
struct Item {
name: String,
}
impl Item {
fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
}
}
}
impl AsMatchStr for Item {
fn as_match_str(&self) -> &str {
&self.name
}
}
#[derive(Debug, PartialEq)]
struct TaggedItem {
name: String,
tags: Vec<String>,
}
impl TaggedItem {
fn new(name: &str, tags: &[&str]) -> Self {
Self {
name: name.to_owned(),
tags: tags.iter().map(|s| (*s).to_owned()).collect(),
}
}
}
impl AsMatchStr for TaggedItem {
fn as_match_str(&self) -> &str {
&self.name
}
}
#[test]
fn basic_string_array_apple_first() {
let items = ["apple", "banana", "grape"];
let results = match_sorter(&items, "ap", MatchSorterOptions::default());
assert!(!results.is_empty(), "should have at least one match");
assert_eq!(results[0], &"apple", "apple should be first (StartsWith)");
assert!(
results.contains(&&"grape"),
"grape should match via Contains"
);
}
#[test]
fn basic_string_array_rank_ordering() {
let items = ["pineapple", "apple", "applesauce"];
let results = match_sorter(&items, "apple", MatchSorterOptions::default());
assert_eq!(results[0], &"apple", "exact match first");
assert_eq!(results[1], &"applesauce", "StartsWith second");
assert_eq!(results[2], &"pineapple", "Contains third");
}
#[test]
fn case_insensitive_matching() {
let items = ["Green", "Red", "Blue"];
let results = match_sorter(&items, "green", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
assert_eq!(results[0], &"Green");
}
#[test]
fn case_sensitive_beats_insensitive() {
let items = ["green", "Green"];
let results = match_sorter(&items, "green", MatchSorterOptions::default());
assert_eq!(results[0], &"green", "exact case match should be first");
assert_eq!(results[1], &"Green", "case-insensitive match second");
}
#[test]
fn diacritics_cafe_matches_accented() {
let items = ["cafe", "caf\u{00e9}", "restaurant"];
let results = match_sorter(&items, "cafe", MatchSorterOptions::default());
assert_eq!(results.len(), 2, "both cafe and cafe should match");
assert!(results.contains(&&"cafe"));
assert!(results.contains(&&"caf\u{00e9}"));
}
#[test]
fn diacritics_kept_no_cross_match() {
let items = ["cafe", "caf\u{00e9}"];
let opts = MatchSorterOptions {
keep_diacritics: true,
..Default::default()
};
let results = match_sorter(&items, "cafe", opts);
assert_eq!(results, vec![&"cafe"], "only exact cafe matches");
}
#[test]
fn threshold_contains_excludes_fuzzy() {
let items = ["apple", "banana", "grape"];
let opts = MatchSorterOptions {
threshold: Ranking::Contains,
..Default::default()
};
let results = match_sorter(&items, "ap", opts);
assert_eq!(results, vec![&"apple", &"grape"]);
}
#[test]
fn threshold_case_sensitive_equal_strict() {
let items = ["apple", "Apple", "APPLE"];
let opts = MatchSorterOptions {
threshold: Ranking::CaseSensitiveEqual,
..Default::default()
};
let results = match_sorter(&items, "apple", opts);
assert_eq!(results, vec![&"apple"]);
}
#[test]
fn key_based_struct_matching() {
let items = vec![Item::new("Alice"), Item::new("Bob"), Item::new("Charlie")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()])],
..Default::default()
};
let results = match_sorter(&items, "ali", opts);
assert!(!results.is_empty());
assert_eq!(results[0].name, "Alice", "Alice matches via StartsWith");
}
#[test]
fn key_based_from_fn() {
let items = vec![Item::new("Delta"), Item::new("Echo"), Item::new("Foxtrot")];
let opts = MatchSorterOptions {
keys: vec![Key::<Item>::from_fn(|i| i.name.as_str())],
..Default::default()
};
let results = match_sorter(&items, "echo", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Echo");
}
#[test]
fn multi_value_key_best_tag_wins() {
let items = vec![
TaggedItem::new("Alice", &["admin", "staff"]),
TaggedItem::new("Bob", &["user"]),
TaggedItem::new("Charlie", &["moderator", "admin"]),
];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &TaggedItem| i.tags.clone())],
..Default::default()
};
let results = match_sorter(&items, "admin", opts);
assert_eq!(results.len(), 2);
let names: Vec<&str> = results.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Alice"));
assert!(names.contains(&"Charlie"));
}
#[test]
fn multi_value_key_from_fn_multi() {
let items = vec![
TaggedItem::new("Server", &["production", "linux"]),
TaggedItem::new("Laptop", &["development", "macos"]),
];
let opts = MatchSorterOptions {
keys: vec![Key::<TaggedItem>::from_fn_multi(|i| {
i.tags.iter().map(|t| t.as_str()).collect()
})],
..Default::default()
};
let results = match_sorter(&items, "linux", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Server");
}
#[test]
fn per_key_max_ranking_clamps_down() {
let items = vec![Item::new("Alice"), Item::new("Bob")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()]).max_ranking(Ranking::Contains)],
..Default::default()
};
let results = match_sorter(&items, "Alice", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Alice");
}
#[test]
fn per_key_min_ranking_promotes() {
let items = vec![Item::new("playground"), Item::new("apple")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()]).min_ranking(Ranking::Contains)],
..Default::default()
};
let results = match_sorter(&items, "plgnd", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "playground");
}
#[test]
fn per_key_min_ranking_does_not_promote_no_match() {
let items = vec![Item::new("abc")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()]).min_ranking(Ranking::Contains)],
..Default::default()
};
let results = match_sorter(&items, "xyz", opts);
assert!(results.is_empty());
}
#[test]
fn custom_base_sort_preserve_original_order() {
let items = ["cherry", "banana", "apple"];
let opts = MatchSorterOptions {
base_sort: Some(Box::new(|a: &RankedItem<&str>, b: &RankedItem<&str>| {
a.index.cmp(&b.index)
})),
..Default::default()
};
let results = match_sorter(&items, "", opts);
assert_eq!(results, vec![&"cherry", &"banana", &"apple"]);
}
#[test]
fn default_base_sort_alphabetical() {
let items = ["cherry", "banana", "apple"];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results, vec![&"apple", &"banana", &"cherry"]);
}
#[test]
fn sorter_override_reverse() {
let items = ["apple", "banana", "grape"];
let default_results = match_sorter(&items, "a", MatchSorterOptions::default());
let opts = MatchSorterOptions {
sorter: Some(Box::new(|mut items: Vec<RankedItem<&str>>| {
items.reverse();
items
})),
..Default::default()
};
let reversed_results = match_sorter(&items, "a", opts);
assert_eq!(reversed_results.len(), default_results.len());
let mut reversed_default = default_results.clone();
reversed_default.reverse();
assert_eq!(reversed_results, reversed_default);
}
#[test]
fn sorter_override_preserve_input_order() {
let items = ["grape", "apple", "banana"];
let opts = MatchSorterOptions {
sorter: Some(Box::new(|mut items: Vec<RankedItem<&str>>| {
items.sort_by_key(|ri| ri.index);
items
})),
..Default::default()
};
let results = match_sorter(&items, "", opts);
assert_eq!(results, vec![&"grape", &"apple", &"banana"]);
}
#[test]
fn empty_query_returns_all_sorted() {
let items = ["banana", "apple", "cherry"];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results.len(), 3, "all items should be returned");
assert_eq!(results[0], &"apple");
assert_eq!(results[1], &"banana");
assert_eq!(results[2], &"cherry");
}
#[test]
fn empty_query_string_items() {
let items = vec!["zebra".to_owned(), "mango".to_owned()];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results.len(), 2);
assert_eq!(results[0].as_str(), "mango");
assert_eq!(results[1].as_str(), "zebra");
}
#[test]
fn single_char_query_matches_substring() {
let items = ["apple", "banana", "plum", "grape"];
let results = match_sorter(&items, "a", MatchSorterOptions::default());
assert!(!results.contains(&&"plum"), "plum has no 'a'");
assert_eq!(results[0], &"apple", "apple starts with 'a'");
assert!(results.contains(&&"banana"));
assert!(results.contains(&&"grape"));
}
#[test]
fn single_char_query_no_match() {
let items = ["hello", "world"];
let results = match_sorter(&items, "z", MatchSorterOptions::default());
assert!(results.is_empty());
}
#[test]
fn acronym_matching_nwa() {
let items = [
"North-West Airlines",
"National Weather Association",
"Something Else",
];
let results = match_sorter(&items, "nwa", MatchSorterOptions::default());
assert!(
results.contains(&&"North-West Airlines"),
"North-West Airlines should match via Acronym"
);
assert!(
results.contains(&&"National Weather Association"),
"National Weather Association also has acronym nwa"
);
assert!(
!results.contains(&&"Something Else"),
"Something Else should not match"
);
}
#[test]
fn acronym_matching_asap() {
let items = ["as soon as possible", "something random"];
let results = match_sorter(&items, "asap", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
assert_eq!(results[0], &"as soon as possible");
}
#[test]
fn word_boundary_fran_matches_san_francisco() {
let items = ["San Francisco", "New York", "Frankfurt"];
let results = match_sorter(&items, "fran", MatchSorterOptions::default());
assert!(results.contains(&&"San Francisco"));
assert!(results.contains(&&"Frankfurt"));
assert_eq!(results[0], &"Frankfurt");
assert_eq!(results[1], &"San Francisco");
}
#[test]
fn word_boundary_hyphen_not_boundary() {
let items = ["North-West", "South West"];
let results = match_sorter(&items, "west", MatchSorterOptions::default());
assert_eq!(
results[0], &"South West",
"South West should rank higher (WordStartsWith)"
);
assert_eq!(
results[1], &"North-West",
"North-West should rank lower (Contains)"
);
}
#[test]
fn edge_empty_items() {
let items: [&str; 0] = [];
let results = match_sorter(&items, "test", MatchSorterOptions::default());
assert!(results.is_empty());
}
#[test]
fn edge_very_long_strings() {
let long_string = "a".repeat(10_000);
let items = [long_string.as_str()];
let results = match_sorter(&items, "a", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
}
#[test]
fn edge_long_query_short_items() {
let items = ["hi", "ok"];
let long_query = "a".repeat(1_000);
let results = match_sorter(&items, &long_query, MatchSorterOptions::default());
assert!(results.is_empty());
}
#[test]
fn edge_empty_string_item() {
let items = ["", "nonempty"];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results.len(), 2);
assert_eq!(results[0], &"", "empty string is CaseSensitiveEqual");
assert_eq!(results[1], &"nonempty", "nonempty is StartsWith");
}
#[test]
fn edge_unicode_items() {
let items = ["\u{4e16}\u{754c}", "hello"];
let results = match_sorter(&items, "\u{4e16}", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
assert_eq!(results[0], &"\u{4e16}\u{754c}");
}
#[test]
fn per_key_threshold_override() {
let items = vec![Item::new("apple"), Item::new("apricot")];
let opts = MatchSorterOptions {
keys: vec![
Key::new(|i: &Item| vec![i.name.clone()]).threshold(Ranking::CaseSensitiveEqual),
],
threshold: Ranking::Matches(1.0), ..Default::default()
};
let results = match_sorter(&items, "apple", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "apple");
}
#[test]
fn threshold_no_match_returns_all() {
let items = ["orange", "apple", "grape", "banana"];
let opts = MatchSorterOptions {
threshold: Ranking::NoMatch,
..Default::default()
};
let results = match_sorter(&items, "ap", opts);
assert_eq!(
results.len(),
items.len(),
"all items should be returned with NoMatch threshold"
);
assert_eq!(results[0], &"apple");
}
#[test]
fn threshold_equal_only_exact() {
let items = ["google", "airbnb", "apple", "apply", "app"];
let opts = MatchSorterOptions {
threshold: Ranking::Equal,
..Default::default()
};
let results = match_sorter(&items, "app", opts);
assert_eq!(results, vec![&"app"]);
}
#[test]
fn threshold_word_starts_with() {
let items = ["fiji apple", "google", "app", "crabapple", "apple", "apply"];
let opts = MatchSorterOptions {
threshold: Ranking::WordStartsWith,
..Default::default()
};
let results = match_sorter(&items, "app", opts);
assert_eq!(results.len(), 4);
assert!(results.contains(&&"app"));
assert!(results.contains(&&"apple"));
assert!(results.contains(&&"apply"));
assert!(results.contains(&&"fiji apple"));
assert!(!results.contains(&&"crabapple"));
assert!(!results.contains(&&"google"));
}
#[test]
fn threshold_word_starts_with_after_suffix() {
let items = [
"fiji apple",
"google",
"app",
"crabapple",
"apple",
"apply",
"snappy apple",
];
let opts = MatchSorterOptions {
threshold: Ranking::WordStartsWith,
..Default::default()
};
let results = match_sorter(&items, "app", opts);
assert!(
results.contains(&&"snappy apple"),
"snappy apple should match via WordStartsWith"
);
assert_eq!(results.len(), 5);
}
#[test]
fn threshold_acronym() {
let items = ["apple", "atop", "alpaca", "vamped"];
let opts = MatchSorterOptions {
threshold: Ranking::Acronym,
..Default::default()
};
let results = match_sorter(&items, "ap", opts);
assert_eq!(results, vec![&"apple"]);
}
#[test]
fn cyrillic_case_insensitive() {
let items = [
"\u{041f}\u{0440}\u{0438}\u{0432}\u{0435}\u{0442}",
"\u{041b}\u{0435}\u{0434}",
];
let results = match_sorter(&items, "\u{043b}", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
assert_eq!(results[0], &"\u{041b}\u{0435}\u{0434}");
}
#[test]
fn fuzzy_sub_score_ordering() {
let items = [
"Antigua and Barbuda",
"India",
"Bosnia and Herzegovina",
"Indonesia",
];
let results = match_sorter(&items, "Ina", MatchSorterOptions::default());
assert!(
results.len() >= 2,
"at least India and Indonesia should match"
);
let india_pos = results.iter().position(|&r| r == &"India");
let indonesia_pos = results.iter().position(|&r| r == &"Indonesia");
assert!(
india_pos.is_some() && indonesia_pos.is_some(),
"both India and Indonesia should match"
);
assert!(
india_pos.unwrap() < indonesia_pos.unwrap(),
"India should sort before Indonesia (same tier, alphabetical tiebreak)"
);
if let Some(antigua_pos) = results.iter().position(|&r| r == &"Antigua and Barbuda") {
assert!(
antigua_pos > indonesia_pos.unwrap(),
"fuzzy matches should sort after Contains matches"
);
}
}
#[test]
fn stable_sort_preserves_insertion_order() {
#[derive(Debug, PartialEq)]
struct CountedItem {
country: String,
counter: usize,
}
impl AsMatchStr for CountedItem {
fn as_match_str(&self) -> &str {
&self.country
}
}
let items = vec![
CountedItem {
country: "Italy".to_owned(),
counter: 1,
},
CountedItem {
country: "Italy".to_owned(),
counter: 2,
},
CountedItem {
country: "Italy".to_owned(),
counter: 3,
},
];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &CountedItem| vec![i.country.clone()])],
..Default::default()
};
let results = match_sorter(&items, "Italy", opts);
assert_eq!(results.len(), 3);
assert_eq!(results[0].counter, 1);
assert_eq!(results[1].counter, 2);
assert_eq!(results[2].counter, 3);
}
#[test]
fn per_key_threshold_more_permissive_than_global() {
#[derive(Debug, PartialEq)]
struct Person {
name: String,
color: String,
}
impl AsMatchStr for Person {
fn as_match_str(&self) -> &str {
&self.name
}
}
let items = vec![
Person {
name: "Fred".to_owned(),
color: "Orange".to_owned(),
},
Person {
name: "Jen".to_owned(),
color: "Red".to_owned(),
},
];
let opts = MatchSorterOptions {
keys: vec![
Key::new(|p: &Person| vec![p.name.clone()]),
Key::new(|p: &Person| vec![p.color.clone()]).threshold(Ranking::Contains),
],
threshold: Ranking::StartsWith, ..Default::default()
};
let results = match_sorter(&items, "ed", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Jen");
}
#[test]
fn diacritics_alphabetical_tiebreaking() {
let items = [
"zoo",
"z\u{00e9}bra", "zigzag",
"azure",
];
let opts = MatchSorterOptions {
threshold: Ranking::NoMatch,
..Default::default()
};
let results = match_sorter(&items, "z", opts);
assert_eq!(
results.len(),
items.len(),
"all items returned with NoMatch threshold"
);
let azure_pos = results
.iter()
.position(|&&r| r == "azure")
.expect("azure should be in results");
let starts_with_items: Vec<&&str> = results[..azure_pos].to_vec();
assert_eq!(starts_with_items.len(), 3);
assert_eq!(starts_with_items[0], &"zigzag");
assert_eq!(starts_with_items[1], &"zoo");
assert_eq!(starts_with_items[2], &"z\u{00e9}bra");
assert_eq!(results[3], &"azure");
}