# matchsorter
Fuzzy string matching and sorting for Rust, inspired by Kent C. Dodds'
[match-sorter](https://github.com/kentcdodds/match-sorter) for JavaScript.
[](https://crates.io/crates/matchsorter)
[](https://docs.rs/matchsorter)
[](LICENSE-MIT)
## Overview
`matchsorter` ranks candidate strings against a search query using an **8-tier
ranking system**, then returns them sorted from best to worst match. It handles
everything from exact equality down to fuzzy character-by-character matching,
with optional diacritics normalization, key extraction for structs, and
per-key ranking controls.
Key differences from the JS library:
- Native Rust performance (4-7x faster on equivalent workloads)
- Zero-copy ranking in no-keys mode (borrows directly from input)
- SIMD-accelerated substring search via `memchr`
- Amortized allocations through reusable buffers and `PreparedQuery`
## Quick Start
Add to your `Cargo.toml`:
```sh
cargo add matchsorter
```
```rust
use matchsorter::{match_sorter, MatchSorterOptions};
let items = ["apple", "banana", "grape", "pineapple"];
let results = match_sorter(&items, "ap", MatchSorterOptions::default());
// "apple" (StartsWith), "grape" (Contains), "pineapple" (Contains); "banana" is dropped
assert_eq!(results, vec![&"apple", &"grape", &"pineapple"]);
```
## Ranking Tiers
Every candidate is classified into one of 8 tiers, checked in order from best
to worst. The first matching tier is used.
| 7 | **CaseSensitiveEqual** | `"app"` matches `"app"` exactly |
| 6 | **Equal** | `"app"` matches `"APP"` (case-insensitive) |
| 5 | **StartsWith** | `"app"` matches `"apple"` |
| 4 | **WordStartsWith** | `"app"` matches `"pine apple"` (word boundary) |
| 3 | **Contains** | `"app"` matches `"pineapple"` (substring) |
| 2 | **Acronym** | `"nwa"` matches `"North-West Airlines"` |
| 1..2 | **Matches** | `"plgnd"` fuzzy-matches `"playground"` |
| 0 | **NoMatch** | No match found |
## Features
- **8-tier ranking** from exact match to fuzzy character matching
- **Diacritics normalization** -- `"cafe"` matches `"cafe"` by default (toggle with `keep_diacritics`)
- **Key extraction** -- match against struct fields with `Key::new`, `Key::from_fn`, or `Key::from_fn_multi`
- **Per-key controls** -- `threshold`, `min_ranking`, and `max_ranking` per key
- **Custom sorting** -- replace the tiebreaker (`base_sort`) or the entire sort (`sorter`)
- **Zero-copy no-keys mode** -- `&str`, `String`, and `Cow<str>` work out of the box via `AsMatchStr`
## Advanced Usage
### Keys mode with structs
```rust
use matchsorter::{match_sorter, MatchSorterOptions, AsMatchStr};
use matchsorter::key::Key;
struct User { name: String, email: String }
// Required for compilation; unused in keys mode.
impl AsMatchStr for User {
fn as_match_str(&self) -> &str { &self.name }
}
let users = vec![
User { name: "Alice".into(), email: "alice@example.com".into() },
User { name: "Bob".into(), email: "bob@example.com".into() },
User { name: "Malika".into(), email: "malika@example.com".into() },
];
let opts = MatchSorterOptions {
keys: vec![
Key::from_fn(|u: &User| u.name.as_str()),
Key::from_fn(|u: &User| u.email.as_str()),
],
..Default::default()
};
let results = match_sorter(&users, "ali", opts);
// "Alice" (StartsWith on name), "Malika" (Contains "ali" in name); "Bob" is dropped
assert_eq!(results.len(), 2);
assert_eq!(results[0].name, "Alice");
assert_eq!(results[1].name, "Malika");
```
### Custom threshold
```rust
use matchsorter::{match_sorter, MatchSorterOptions, Ranking};
let items = ["apple", "banana", "playground"];
let opts = MatchSorterOptions {
threshold: Ranking::Contains,
..Default::default()
};
// Only items with a Contains ranking or better are returned.
// Fuzzy-only matches are excluded.
let results = match_sorter(&items, "pl", opts);
assert_eq!(results.len(), 2); // "apple" (Contains) and "playground" (StartsWith)
```
### Per-key clamping
```rust
use matchsorter::{match_sorter, MatchSorterOptions, AsMatchStr, Ranking};
use matchsorter::key::Key;
struct Item { name: String, description: String }
impl AsMatchStr for Item {
fn as_match_str(&self) -> &str { &self.name }
}
let items = vec![
Item { name: "Rust".into(), description: "A systems programming language".into() },
Item { name: "Trust".into(), description: "Build with trust and safety".into() },
];
let opts = MatchSorterOptions {
keys: vec![
Key::from_fn(|i: &Item| i.name.as_str()),
// Cap description matches to Contains so name matches always win
Key::from_fn(|i: &Item| i.description.as_str())
.max_ranking(Ranking::Contains),
],
..Default::default()
};
let results = match_sorter(&items, "rust", opts);
// "Rust" (Equal on name), "Trust" (Contains on name); description caps don't outrank name
assert_eq!(results.len(), 2);
assert_eq!(results[0].name, "Rust");
assert_eq!(results[1].name, "Trust");
```
## Performance
Benchmarked against the JS `match-sorter` library (Node.js v22) on 10,000
items. All times are median microseconds.
| Throughput (10k items) | 455 us | 2,671 us | **5.9x** |
| Exact match | 351 us | 2,126 us | **6.1x** |
| Prefix match | 298 us | 2,214 us | **7.4x** |
| Substring match | 356 us | 1,915 us | **5.4x** |
| Fuzzy match | 460 us | 2,705 us | **5.9x** |
| No match (early exit) | 327 us | 1,706 us | **5.2x** |
| Diacritics strip (10k) | 761 us | 2,935 us | **3.9x** |
See [`bench-compare/`](bench-compare/) for reproduction instructions.
## License
Licensed under either of
- [Apache License, Version 2.0](LICENSE-APACHE)
- [MIT License](LICENSE-MIT)
at your option.