use nucleo_matcher::{
pattern::{CaseMatching, Normalization, Pattern},
Matcher,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntryKind {
Session,
Pane,
Tab,
Command,
Recent,
}
impl EntryKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Session => "session",
Self::Pane => "pane",
Self::Tab => "tab",
Self::Command => "command",
Self::Recent => "recent",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub kind: EntryKind,
pub display: String,
pub payload: String,
pub bias: i32,
}
impl Entry {
pub fn new(kind: EntryKind, display: impl Into<String>) -> Self {
let display = display.into();
Self {
kind,
payload: display.clone(),
display,
bias: 0,
}
}
pub fn with_payload(mut self, payload: impl Into<String>) -> Self {
self.payload = payload.into();
self
}
pub fn with_bias(mut self, bias: i32) -> Self {
self.bias = bias;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Match {
pub index: usize,
pub score: i32,
}
pub struct FuzzyIndex {
entries: Vec<Entry>,
matcher: Matcher,
}
impl FuzzyIndex {
pub fn new(entries: Vec<Entry>) -> Self {
Self {
entries,
matcher: Matcher::default(),
}
}
pub fn entries(&self) -> &[Entry] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn search(&mut self, query: &str, limit: usize) -> Vec<Match> {
if query.is_empty() {
return self
.entries
.iter()
.enumerate()
.take(limit)
.map(|(i, e)| Match {
index: i,
score: e.bias,
})
.collect();
}
let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let mut scored: Vec<Match> = Vec::with_capacity(self.entries.len());
let mut buf = Vec::new();
for (i, entry) in self.entries.iter().enumerate() {
buf.clear();
let utf32 = nucleo_matcher::Utf32Str::new(&entry.display, &mut buf);
if let Some(score) = pattern.score(utf32, &mut self.matcher) {
let total = score as i32 + entry.bias;
scored.push(Match {
index: i,
score: total,
});
}
}
scored.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index)));
scored.truncate(limit);
scored
}
}
pub const HISTORY_CAP: usize = 200;
pub const HISTORY_VIEW_CAP: usize = 20;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct History {
#[serde(default)]
pub commands: Vec<String>,
}
impl History {
pub fn push(&mut self, command: impl Into<String>) {
let cmd = command.into();
if cmd.trim().is_empty() {
return;
}
self.commands.retain(|c| c != &cmd);
self.commands.insert(0, cmd);
if self.commands.len() > HISTORY_CAP {
self.commands.truncate(HISTORY_CAP);
}
}
pub fn as_entries(&self) -> Vec<Entry> {
self.commands
.iter()
.take(HISTORY_VIEW_CAP)
.map(|c| Entry::new(EntryKind::Recent, c.clone()).with_bias(500))
.collect()
}
pub fn load(path: &std::path::Path) -> Self {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return Self::default(),
};
toml::from_str(&raw).unwrap_or_default()
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, body)
}
}
pub fn history_path() -> PathBuf {
let dir = std::env::var("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let mut home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"));
home.push(".local");
home.push("state");
home
});
dir.join("ezpn").join("history.toml")
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd(s: &str) -> Entry {
Entry::new(EntryKind::Command, s)
}
#[test]
fn empty_query_returns_entries_in_order() {
let mut idx = FuzzyIndex::new(vec![cmd("alpha"), cmd("beta"), cmd("gamma")]);
let m = idx.search("", 10);
assert_eq!(m.len(), 3);
assert_eq!(m[0].index, 0);
assert_eq!(m[1].index, 1);
assert_eq!(m[2].index, 2);
}
#[test]
fn ranks_kill_pane_above_unrelated_for_kil_query() {
let mut idx = FuzzyIndex::new(vec![
cmd("split-window -h"),
cmd("split-window -v"),
cmd("new-window"),
cmd("kill-pane"),
cmd("kill-window"),
cmd("rename-session"),
]);
let m = idx.search("kil", 10);
assert!(!m.is_empty(), "expected at least one match");
let top = &idx.entries()[m[0].index];
assert!(
top.display.starts_with("kill"),
"top match for 'kil' should start with 'kill', got {:?}",
top.display
);
}
#[test]
fn limit_truncates_results() {
let entries: Vec<Entry> = (0..50).map(|i| cmd(&format!("cmd-{i}"))).collect();
let mut idx = FuzzyIndex::new(entries);
let m = idx.search("cmd", 5);
assert_eq!(m.len(), 5);
}
#[test]
fn recent_bias_outranks_command_with_same_substring() {
let mut idx = FuzzyIndex::new(vec![
cmd("kill-pane"),
Entry::new(EntryKind::Recent, "kill-pane").with_bias(500),
]);
let m = idx.search("kill", 10);
assert!(m.len() >= 2);
assert_eq!(idx.entries()[m[0].index].kind, EntryKind::Recent);
}
#[test]
fn smart_case_lower_query_is_case_insensitive() {
let mut idx = FuzzyIndex::new(vec![cmd("Kill-Pane"), cmd("rename-Session")]);
let m = idx.search("kill", 10);
assert!(!m.is_empty());
assert!(idx.entries()[m[0].index].display.starts_with("Kill"));
}
#[test]
fn no_match_returns_empty() {
let mut idx = FuzzyIndex::new(vec![cmd("alpha"), cmd("beta")]);
let m = idx.search("zzz", 10);
assert!(m.is_empty());
}
#[test]
fn history_push_collapses_duplicates() {
let mut h = History::default();
h.push("split-window -h");
h.push("kill-pane");
h.push("split-window -h"); assert_eq!(h.commands, vec!["split-window -h", "kill-pane"]);
}
#[test]
fn history_caps_at_200() {
let mut h = History::default();
for i in 0..250 {
h.push(format!("cmd-{i}"));
}
assert_eq!(h.commands.len(), HISTORY_CAP);
assert_eq!(h.commands[0], "cmd-249");
}
#[test]
fn history_skips_blank() {
let mut h = History::default();
h.push("");
h.push(" ");
assert!(h.commands.is_empty());
}
#[test]
fn history_persists_across_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history.toml");
let mut h = History::default();
h.push("kill-pane");
h.push("split-window");
h.save(&path).unwrap();
let loaded = History::load(&path);
assert_eq!(loaded.commands, vec!["split-window", "kill-pane"]);
}
#[test]
fn history_load_missing_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.toml");
let loaded = History::load(&path);
assert!(loaded.commands.is_empty());
}
#[test]
fn history_as_entries_caps_at_view_limit() {
let mut h = History::default();
for i in 0..50 {
h.push(format!("cmd-{i}"));
}
assert_eq!(h.as_entries().len(), HISTORY_VIEW_CAP);
assert_eq!(h.as_entries()[0].kind, EntryKind::Recent);
assert_eq!(h.as_entries()[0].bias, 500);
}
#[test]
fn one_thousand_entries_search_meets_60fps_budget() {
let entries: Vec<Entry> = (0..1000)
.map(|i| cmd(&format!("pane: nvim-{i} @ session/main")))
.collect();
let mut idx = FuzzyIndex::new(entries);
let _ = idx.search("nvim", 8);
let start = std::time::Instant::now();
const ITERS: u32 = 100;
for _ in 0..ITERS {
let _ = idx.search("nvim", 8);
}
let elapsed = start.elapsed();
let per_iter = elapsed / ITERS;
eprintln!(
"fuzzy bench: 1000 entries x 100 iters = {elapsed:?} \
({:?} per search)",
per_iter
);
#[cfg(not(debug_assertions))]
assert!(
per_iter.as_millis() < 16,
"1k-entry search took {per_iter:?} — over the 16ms (60 FPS) frame budget"
);
#[cfg(debug_assertions)]
{
let _ = per_iter;
}
}
}