Build a Fuzzy Finder in Rust With Crossterm and Nucleo
A fuzzy finder reads lines, filters them as you type and prints the one you pick. The pieces are raw mode input, a matching engine and a small redraw loop.

A fuzzy finder is a small program that changed how people use a terminal. You pipe it a list, a thousand file names, your command history, a hundred branches, type a few letters from anywhere in the item you want, and it narrows the list live and hands back your choice. fzf made this a habit. Building one is a good Rust project because it splits cleanly into two halves that need different skills: an engine that ranks items against a query, and a terminal interface that draws results and reacts to keys. The engine is pure and testable. The interface is where the platform surprises live, and this guide hits one of them on macOS.
The finder here, rfz, uses nucleo-matcher 0.3.1 for scoring and crossterm 0.29.0 for the terminal. It was built and tested on Rust 1.99 (edition 2024) on macOS. The interface was exercised end to end through a pseudo-terminal, not by hand, and every output below is real.
What fuzzy matching is
A fuzzy match asks whether the characters of the query appear in the candidate in order, not necessarily next to each other. The query smr matches src/main.rs because s, m and r all occur, in that order. That is a subsequence match, and by itself it is too loose to be useful: it matches nearly everything long enough. The value is in the ranking, which scores a match higher when it looks like what a person meant. Consecutive characters score more than scattered ones. A match at the start of a word, after a /, _ or -, or at a capital letter inside camelCase, scores more than one in the middle of a word. A match in the file name scores more than one in a directory. Shorter candidates win ties.
You do not write that scoring. nucleo-matcher implements it, the same family of algorithm that powers the Helix editor, and exposes it through Pattern and Matcher. What you write is everything around it, and that is enough work to fill an evening.
The engine
The engine takes a list of strings and a query and returns ranked hits, each carrying the index of the item, its score and the positions of the matched characters, which the interface needs for highlighting.
//! The engine of a fuzzy finder: matching, ranking and the state a UI drives.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
/// One ranked result: the index of the item, its score and the matched character positions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
pub index: usize,
pub score: u32,
pub positions: Vec<u32>,
}
pub struct Finder {
matcher: Matcher,
items: Vec<String>,
}
impl Finder {
pub fn new(items: Vec<String>) -> Self {
// `match_paths` gives bonuses for matches after `/` and at the start of a file name.
Finder { matcher: Matcher::new(Config::DEFAULT.match_paths()), items }
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn item(&self, index: usize) -> &str {
&self.items[index]
}
/// Rank every item against the query, best first. An empty query matches everything in order.
pub fn search(&mut self, query: &str, limit: usize) -> Vec<Hit> {
if query.is_empty() {
return (0..self.items.len().min(limit)).map(|index| Hit { index, score: 0, positions: vec![] }).collect();
}
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let mut buf = Vec::new();
let mut hits = Vec::new();
for (index, item) in self.items.iter().enumerate() {
let haystack = Utf32Str::new(item, &mut buf);
let mut positions = Vec::new();
if let Some(score) = pattern.indices(haystack, &mut self.matcher, &mut positions) {
positions.sort_unstable();
positions.dedup();
hits.push(Hit { index, score, positions });
}
}
// Higher score first; ties keep the original order, which makes results stable.
hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index)));
hits.truncate(limit);
hits
}
}
impl Finder {
/// Rank only the items named in `candidates`. When a query grows by one character, every
/// match must already be among the previous hits, so the search can start from those.
pub fn search_among(&mut self, query: &str, candidates: &[usize], limit: usize) -> Vec<Hit> {
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let mut buf = Vec::new();
let mut hits = Vec::new();
for &index in candidates {
let haystack = Utf32Str::new(&self.items[index], &mut buf);
let mut positions = Vec::new();
if let Some(score) = pattern.indices(haystack, &mut self.matcher, &mut positions) {
positions.sort_unstable();
positions.dedup();
hits.push(Hit { index, score, positions });
}
}
hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index)));
hits.truncate(limit);
hits
}
/// Same result as `search`, using every core: split the items, rank each chunk with its own
/// matcher, and merge the best.
pub fn search_parallel(&self, query: &str, limit: usize, threads: usize) -> Vec<Hit> {
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let chunk = self.items.len().div_ceil(threads.max(1));
let mut all: Vec<Hit> = std::thread::scope(|scope| {
let handles: Vec<_> = self
.items
.chunks(chunk.max(1))
.enumerate()
.map(|(n, slice)| {
let pattern = &pattern;
scope.spawn(move || {
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
let mut buf = Vec::new();
let mut local = Vec::new();
for (offset, item) in slice.iter().enumerate() {
let mut positions = Vec::new();
if let Some(score) = pattern.indices(Utf32Str::new(item, &mut buf), &mut matcher, &mut positions) {
positions.sort_unstable();
positions.dedup();
local.push(Hit { index: n * chunk + offset, score, positions });
}
}
local.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index)));
local.truncate(limit);
local
})
})
.collect();
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
all.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index)));
all.truncate(limit);
all
}
}
/// What the UI loop should do after a key press.
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
None,
Quit,
Accept(usize),
}
/// The state behind the screen: a query, the ranked results and a selected row.
pub struct App {
pub finder: Finder,
pub query: String,
pub hits: Vec<Hit>,
pub selected: usize,
limit: usize,
}
impl App {
pub fn new(items: Vec<String>, limit: usize) -> Self {
let mut app = App { finder: Finder::new(items), query: String::new(), hits: vec![], selected: 0, limit };
app.refresh();
app
}
fn refresh(&mut self) {
self.hits = self.finder.search(&self.query, self.limit);
self.selected = self.selected.min(self.hits.len().saturating_sub(1));
}
pub fn on_key(&mut self, key: KeyEvent) -> Action {
match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit,
(KeyCode::Enter, _) => match self.hits.get(self.selected) {
Some(hit) => Action::Accept(hit.index),
None => Action::None,
},
(KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
self.selected = self.selected.saturating_sub(1);
Action::None
}
(KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
if self.selected + 1 < self.hits.len() {
self.selected += 1;
}
Action::None
}
(KeyCode::Backspace, _) => {
self.query.pop();
self.selected = 0;
self.refresh();
Action::None
}
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
self.query.push(c);
self.selected = 0;
self.refresh();
Action::None
}
_ => Action::None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn paths() -> Vec<String> {
["src/main.rs", "src/matcher/mod.rs", "src/ui/mod.rs", "README.md", "tests/main_test.rs", "docs/Main.md", "Cargo.toml"]
.iter()
.map(|s| s.to_string())
.collect()
}
fn key(c: KeyCode) -> KeyEvent {
KeyEvent::new(c, KeyModifiers::NONE)
}
#[test]
fn an_empty_query_lists_everything_in_order() {
let mut f = Finder::new(paths());
let hits = f.search("", 100);
assert_eq!(hits.len(), 7);
assert_eq!(hits[0].index, 0);
}
#[test]
fn subsequence_matches_rank_the_file_name_hit_first() {
let mut f = Finder::new(paths());
let hits = f.search("main", 100);
let top = f.item(hits[0].index);
assert_eq!(top, "src/main.rs");
assert!(hits.iter().any(|h| f.item(h.index) == "tests/main_test.rs"));
}
#[test]
fn letters_must_appear_in_order_but_need_not_touch() {
let mut f = Finder::new(paths());
let found: Vec<_> = f.search("smr", 10).iter().map(|h| f.item(h.index).to_string()).collect();
assert!(found.contains(&"src/main.rs".to_string()));
assert!(f.search("nms", 10).iter().all(|h| f.item(h.index) != "src/main.rs"));
}
#[test]
fn smart_case_is_insensitive_until_you_type_a_capital() {
let mut f = Finder::new(paths());
assert!(f.search("main", 10).iter().any(|h| f.item(h.index) == "docs/Main.md"));
let upper: Vec<_> = f.search("Main", 10).iter().map(|h| f.item(h.index).to_string()).collect();
assert_eq!(upper, ["docs/Main.md"]);
}
#[test]
fn positions_point_at_the_matched_characters() {
let mut f = Finder::new(vec!["src/main.rs".into()]);
let hit = &f.search("mn", 1)[0];
let chars: Vec<char> = "src/main.rs".chars().collect();
let matched: String = hit.positions.iter().map(|&p| chars[p as usize]).collect();
assert_eq!(matched, "mn");
}
#[test]
fn keys_edit_the_query_move_the_selection_and_accept() {
let mut app = App::new(paths(), 50);
for c in "main".chars() {
assert_eq!(app.on_key(key(KeyCode::Char(c))), Action::None);
}
assert_eq!(app.query, "main");
app.on_key(key(KeyCode::Down));
assert_eq!(app.selected, 1);
app.on_key(key(KeyCode::Up));
app.on_key(key(KeyCode::Up)); // already at the top: stays there
assert_eq!(app.selected, 0);
let first = app.hits[0].index;
assert_eq!(app.on_key(key(KeyCode::Enter)), Action::Accept(first));
app.on_key(key(KeyCode::Backspace));
assert_eq!(app.query, "mai");
assert_eq!(app.on_key(key(KeyCode::Esc)), Action::Quit);
assert_eq!(app.on_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), Action::Quit);
}
#[test]
fn narrowing_and_parallel_search_agree_with_the_full_search() {
let items: Vec<String> = (0..2000).map(|i| format!("dir{}/file{}_{}.rs", i % 17, i, i * 7)).collect();
let mut f = Finder::new(items);
let full = f.search("fil1", 50);
let before = f.search("fil", usize::MAX);
let candidates: Vec<usize> = before.iter().map(|h| h.index).collect();
assert_eq!(f.search_among("fil1", &candidates, 50), full);
assert_eq!(f.search_parallel("fil1", 50, 4), full);
}
#[test]
fn accepting_with_no_results_does_nothing() {
let mut app = App::new(paths(), 50);
for c in "zzzzzz".chars() {
app.on_key(key(KeyCode::Char(c)));
}
assert!(app.hits.is_empty());
assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None);
}
}Several choices in Finder::search are worth noticing. The matcher is configured with Config::DEFAULT.match_paths(), which adds bonuses for matches after a path separator, so main prefers src/main.rs over src/domain/model.rs. Pattern::parse with CaseMatching::Smart implements the convention users expect from fzf and ripgrep: the match ignores case until the query contains a capital letter, and then it becomes case-sensitive. An empty query returns everything in the original order, so the screen is useful before the first keystroke. Results sort by score, highest first, with the original index as a tiebreaker, which makes the output stable: two items with the same score always appear in the order they were supplied, so the list does not shuffle as you type. And the search returns at most limit hits, because a screen shows 40 lines, not 250,000.
Run on a small list of file names, the engine ranks like this:
query "main"
109 src/[m][a][i][n].rs
109 tests/[m][a][i][n]_test.rs
109 docs/[M][a][i][n].md
76 src/do[m][a][i][n]/model.rs
query "smr"
72 [s]rc/[m]ain.[r]s
70 [s]rc/ui/[m]od.[r]s
65 [s]rc/[m]atcher/mod.[r]s
64 [s]rc/domain/[m]odel.[r]s
query "Main"
109 docs/[M][a][i][n].md
query "mod"
84 src/matcher/[m][o][d].rs
84 src/ui/[m][o][d].rs
84 src/domain/[m][o][d]el.rs
query "cargo"
134 [C][a][r][g][o].tomlThe brackets mark the characters that matched, which is the positions field. Read the results the way a user would. For main, the three files whose names are main score 109 each and tie, and src/domain/model.rs, where the letters of main appear inside the word domain, scores 76. The scattered query smr finds src/main.rs first, with matches at the start of src, the start of main and the start of the extension. The capital in Main switches off case-insensitivity, so only docs/Main.md qualifies. For mod, three files score the same 84, and the stable sort keeps them in input order. The cargo query puts the exact word first at 134.
Tests that pin the behavior
Because the engine is a plain struct with no terminal in it, its behavior is easy to specify. Eight tests cover it. Here are the two that expose the most:
#[test]
fn letters_must_appear_in_order_but_need_not_touch() {
let mut f = Finder::new(paths());
let found: Vec<_> = f.search("smr", 10).iter().map(|h| f.item(h.index).to_string()).collect();
assert!(found.contains(&"src/main.rs".to_string()));
assert!(f.search("nms", 10).iter().all(|h| f.item(h.index) != "src/main.rs"));
}
#[test]
fn positions_point_at_the_matched_characters() {
let mut f = Finder::new(vec!["src/main.rs".into()]);
let hit = &f.search("mn", 1)[0];
let chars: Vec<char> = "src/main.rs".chars().collect();
let matched: String = hit.positions.iter().map(|&p| chars[p as usize]).collect();
assert_eq!(matched, "mn");
}test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02sThe first test originally used the out-of-order query rms and failed, and it is a good story about fuzzy matching. The test claimed rms should not match src/main.rs, since m comes before r in the word main. But the subsequence is r from src, then m from main, then s from rs, which is in order. The matcher was right and the test was wrong. A query that truly cannot match is nms: the only m in the path comes before the only n, so no m follows an n. When you write tests for a fuzzy matcher, check by hand that your negative examples are genuinely impossible.
The state machine: keys in, actions out
The interface separates what happens from how it looks. App holds the query, the ranked hits and the selected row, and on_key takes a key event and returns an Action: nothing, quit, or accept a particular item.
pub fn on_key(&mut self, key: KeyEvent) -> Action {
match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit,
(KeyCode::Enter, _) => match self.hits.get(self.selected) {
Some(hit) => Action::Accept(hit.index),
None => Action::None,
},
(KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { /* move up */ Action::None }
(KeyCode::Backspace, _) => { self.query.pop(); self.selected = 0; self.refresh(); Action::None }
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
self.query.push(c);
self.selected = 0;
self.refresh();
Action::None
}
_ => Action::None,
}
}Because KeyEvent is a plain value you can construct in a test, the whole interaction is testable with no terminal. The test keys_edit_the_query_move_the_selection_and_accept types main, presses Down, Up, Up (which stops at the top), Enter, Backspace, Escape and Ctrl-C, asserting the state after each. Another test checks that pressing Enter with no results does nothing instead of accepting a non-existent item. The state machine is the place where most bugs in an interactive tool live, and having it free of I/O means you find them in milliseconds.
Two details of the design are worth stating. The selection resets to the top whenever the query changes, because the old position is meaningless in a new list, and refresh clamps it into range when the hits shrink. And Control-modified letters are not typed into the query: the guard !m.contains(KeyModifiers::CONTROL) is what keeps Ctrl-N and Ctrl-P as navigation instead of inserting an n or a p.
The terminal layer
The interface is thin. It switches the terminal to raw mode and the alternate screen, runs a loop that draws and then waits for a key, and restores the terminal on exit. One design choice makes it usable in a shell. The program draws on standard error and prints the chosen item on standard output, so a shell can capture the selection while the interface still appears on screen:
use std::io::{self, BufRead, IsTerminal, Write};
use std::process::ExitCode;
use std::time::Duration;
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::style::{Attribute, Print, SetAttribute};
use crossterm::terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{execute, queue};
use rfz::{Action, App, Hit};
/// Draw the prompt and the visible results. The screen is written to stderr so that the chosen
/// item can go to stdout and be captured by a shell: `vim "$(find . -type f | rfz)"`.
fn draw(out: &mut impl Write, app: &App, rows: u16) -> io::Result<()> {
queue!(out, MoveTo(0, 0), Clear(ClearType::All), Print(format!("> {}", app.query)))?;
for (row, hit) in app.hits.iter().take(rows.saturating_sub(2) as usize).enumerate() {
queue!(out, MoveTo(0, row as u16 + 1))?;
queue!(out, Print(if row == app.selected { "> " } else { " " }))?;
print_highlighted(out, app.finder.item(hit.index), hit)?;
}
out.flush()
}
fn print_highlighted(out: &mut impl Write, text: &str, hit: &Hit) -> io::Result<()> {
for (i, ch) in text.chars().enumerate() {
if hit.positions.contains(&(i as u32)) {
queue!(out, SetAttribute(Attribute::Bold), Print(ch), SetAttribute(Attribute::Reset))?;
} else {
queue!(out, Print(ch))?;
}
}
Ok(())
}
fn run(items: Vec<String>) -> io::Result<Option<String>> {
let mut app = App::new(items, 200);
let mut screen = io::stderr();
terminal::enable_raw_mode()?;
execute!(screen, EnterAlternateScreen, Hide)?;
let result = (|| -> io::Result<Option<String>> {
loop {
let (_, rows) = terminal::size()?;
draw(&mut screen, &app, rows)?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match app.on_key(key) {
Action::Quit => return Ok(None),
Action::Accept(i) => return Ok(Some(app.finder.item(i).to_string())),
Action::None => {}
}
}
}
})();
// Restore the terminal on every path, including errors.
execute!(screen, Show, LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
result
}
fn main() -> ExitCode {
if io::stdin().is_terminal() {
eprintln!("rfz: pipe a list of candidates into it, for example: find . -type f | rfz");
return ExitCode::from(2);
}
let items: Vec<String> = io::stdin().lock().lines().map_while(Result::ok).filter(|l| !l.is_empty()).collect();
match run(items) {
Ok(Some(choice)) => {
println!("{choice}");
ExitCode::SUCCESS
}
Ok(None) => ExitCode::from(130),
Err(e) => {
eprintln!("rfz: {e}");
ExitCode::from(2)
}
}
}That is the same trick fzf uses, and it makes this idiom work: vim "$(find . -type f | rfz)". Standard input carries the candidates, standard output carries the result, and the terminal itself, opened separately, carries the interaction. The exit status distinguishes the cases: 0 for a choice, 130 for a cancel, matching the shell convention for an interrupt, and 2 for errors. The restore call runs after the inner loop returns whether it succeeded or failed, so a failure never leaves the user's terminal in raw mode; a terminal left in raw mode looks like a shell that has stopped echoing your typing.
The error that only appears when stdin is a pipe
The first build of this program compiled, passed its tests, and failed the first time it ran as a real finder, which is the only time the problem can show. Run under a pseudo-terminal with candidates piped into it, it printed:
rfz: Failed to initialize input readerThe cause is a platform detail. A finder reads its candidates from standard input, so standard input is a pipe. Then it must read keystrokes from somewhere else, and the only place left is the controlling terminal, /dev/tty. On Linux, crossterm reads keys from the terminal device when stdin is not a terminal. On macOS it uses a mechanism that cannot poll /dev/tty, and initializing the event reader fails. The fix is a crate feature that opts into a different reader that opens /dev/tty directly:
[dependencies]
crossterm = { version = "0.29", features = ["use-dev-tty", "libc"] }
nucleo-matcher = "0.3"
anyhow = "1"The lesson generalizes: a terminal interface that also consumes piped input needs a keyboard source that is independent of stdin. Always test the real combination, producer | tool, in a real terminal, because a unit test of the state machine will not catch it.
Testing the real thing through a pseudo-terminal
A tool that reads a terminal can be tested end to end by giving it a pseudo-terminal and playing the user. Python's pty module does it in a few lines: fork with a pty as the controlling terminal, run a shell command that pipes four candidate paths into rfz and echoes the result, send the keystrokes b, e, t, the Down arrow and Enter, and read what comes out. The run printed the screen redraws as escape sequences, eleven of them as the keys arrived, and then the shell's echo of the choice:
11 screens drawn
chose: docs/better.mdThe candidates were src/alpha.rs, src/beta.rs, docs/better.md and README.md. Typing bet leaves two matches, src/beta.rs and docs/better.md; Down moved the selection to the second; Enter returned it. The whole chain, key decoding, query editing, matching, selection and the output on stdout captured by $(...), worked. Keep a script like this in the repository; it is the only test that exercises the terminal setup and teardown.
How fast is the engine?
An interactive finder has about 50 milliseconds before the delay is noticeable. The benchmark used 253,500 paths, built by repeating a real listing of 25,350 files from a Cargo target directory ten times with different prefixes, and ran each query once from scratch in a release build on an Apple silicon laptop:
253500 paths (25350 unique shapes)
m: 20.8ms
main: 37.7ms
src/lib: 23.1ms
serdejson: 19.2ms
xyzzy: 10.9msEvery query finished well under 50 ms on a quarter of a million paths with one thread, which is fast enough that the simple design, re-scoring everything on every keystroke, is the right one to ship first. The slowest query, main, matched the most items and so did the most scoring and sorting. A query that matches nothing is the fastest, but notice the last row: xyzzy does match something in this data, because the list contains hash-named build artifacts with long random file names, and a five-letter subsequence appears by chance in a long enough string. That is the practical weakness of fuzzy matching on long, random-looking strings, and it is why the ranking matters more than the match.
When the simple design stops being fast enough
The search is linear in the number of candidates. Two standard improvements exist, and the measurements show that only one of them helps here, which is why you measure before adding either.
The first is narrowing. When a new query extends the previous one by a character, every match must already be among the previous hits, so the search can start from those. search_among ranks only the indices you give it. The test narrowing_and_parallel_search_agree_with_the_full_search checks that it returns exactly what a full search returns. Typing serde one key at a time over the 253,500 paths gave these timings, with the full scan and the narrowed search measured for every prefix:
query matches full scan narrowed
s 241440 25.8ms 27.2ms
se 214210 120.0ms 91.4ms
ser 167100 92.2ms 93.2ms
serd 76030 55.2ms 63.1ms
serde 48130 46.5ms 40.8ms
serde_ 14010 27.7ms 14.7msNarrowing barely helped. The reason is in the second column: paths are long strings, and a short query is a subsequence of almost all of them, so the letter s alone matches 241,440 of 253,500 paths. Narrowing from 241,000 to 214,000 candidates saves nothing worth having. It starts to pay only when the query becomes selective, as in the last row, where it halves the time. The takeaway is not that narrowing is useless, it is that the benefit depends on how selective the first characters are in your data. For short strings such as branch names or commands, short queries are selective and narrowing works well. For long file paths it does not.
The second improvement is parallelism, and it helps unconditionally because the work is independent per item. search_parallel splits the items into chunks, gives each thread its own Matcher (the matcher holds scratch space and cannot be shared), ranks its chunk, and merges the best from each. The five-keystroke typing sequence on the full list took:
typing s-e-r-d-e over 253500 paths
rescan everything each key: 317 to 327 ms
narrow from previous hits: 315 to 328 ms
8 threads, rescan each key: 62 to 77 msThose are totals for five keystrokes, so the parallel version spent about 13 ms per key against about 64 ms for the single-threaded scan, a speedup of roughly five on this machine. This is the improvement to reach for first. The numbers also show where the ceiling is: the merge and the final sort of all matching items are single-threaded, and with 240,000 matches that sort is a real cost. Keeping only the top limit hits per thread, as the code does, with a bounded heap instead of a full sort, is the next optimization.
A third change matters more for feel than for raw speed: do the search off the interface thread. If the search takes 60 ms, the screen should still accept keystrokes during it and drop results that were computed for a query the user has since changed. The nucleo crate packages exactly this, with a worker pool, a stream of items that can arrive while searching, and snapshots the interface reads. Move to it when your lists are large or slow to produce. For lists under 100,000 items, the single-threaded engine in this guide is already fast enough to feel instant.
Extensions
Each of these teaches a distinct part of the problem. A preview pane that shows the first lines of the selected file. Multi-select with Tab, returning several lines. A --query flag to start with text already typed. Highlighting in the drawn result rows, which the code already does with bold text on the positions. Mouse support. Shell key bindings, so Ctrl-R searches history and Ctrl-T inserts a path. And a mode that reads NUL-separated items for paths containing newlines. For the other tools in this family, see a fast file finder with parallel walking, which produces the lists you pipe in, and terminal user interfaces with Ratatui, which replaces the hand-written drawing with a layout system when the screen grows beyond a list.
Wiring it into a shell
A finder earns its keep through the shell bindings around it. The simplest is a function that picks a file and opens it: edit() { local f; f="$(find . -type f -not -path './.git/*' | rfz)" && ${EDITOR:-vi} "$f"; }. The && is doing real work: when the user cancels with Escape the program exits with status 130, the capture is empty, and the editor is never started. For history search, feed it fc -ln 1 and insert the choice into the command line with a key binding in your shell's line editor. For branches, git branch --format='%(refname:short)' | rfz | xargs git checkout is a one-line branch switcher. Each of these works because of the three decisions made earlier: candidates on stdin, the chosen item alone on stdout, and the interface and diagnostics on standard error, so none of the plumbing sees the screen.
Test the bindings the way you test the program: with a pseudo-terminal and a known candidate list, asserting on the text the shell function receives. A binding that works on your machine and breaks under a different shell or without a controlling terminal, such as inside a cron job or a CI container where no /dev/tty exists, should fail with a clear message and a non-zero status rather than hang waiting for keys that will never come.


