use zero_commands::{COMMAND_CATALOG, CommandInfo};
pub const PICKER_MAX_VISIBLE: usize = 6;
#[derive(Debug)]
pub struct SlashPicker {
matches: Vec<SlashMatch>,
selected: usize,
}
#[derive(Debug, Clone)]
pub struct SlashMatch {
pub info: CommandInfo,
pub matched_chars: Vec<usize>,
}
impl SlashPicker {
#[must_use]
pub fn from_prompt_line(first_line: &str) -> Option<Self> {
let rest = first_line.strip_prefix('/')?;
let filter: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
Some(Self::filter_catalog(&filter))
}
fn filter_catalog(filter: &str) -> Self {
let needle = filter.to_ascii_lowercase();
let mut scored: Vec<(i64, SlashMatch)> = Vec::new();
for info in COMMAND_CATALOG {
let candidate = info.name.strip_prefix('/').unwrap_or(info.name);
if let Some((score, matched_chars)) = fuzzy_score(&needle, candidate) {
let shifted = matched_chars.iter().map(|i| i + 1).collect();
scored.push((
score,
SlashMatch {
info: *info,
matched_chars: shifted,
},
));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0));
let matches: Vec<SlashMatch> = scored.into_iter().map(|(_, m)| m).collect();
Self {
matches,
selected: 0,
}
}
#[must_use]
pub fn is_active(&self) -> bool {
!self.matches.is_empty()
}
#[must_use]
pub fn matches(&self) -> &[SlashMatch] {
&self.matches
}
#[must_use]
pub const fn selected_index(&self) -> usize {
self.selected
}
#[must_use]
pub fn selected(&self) -> Option<&SlashMatch> {
self.matches.get(self.selected)
}
pub fn select_next(&mut self) {
if self.matches.is_empty() {
return;
}
self.selected = (self.selected + 1) % self.matches.len();
}
pub fn select_prev(&mut self) {
if self.matches.is_empty() {
return;
}
self.selected = if self.selected == 0 {
self.matches.len() - 1
} else {
self.selected - 1
};
}
#[must_use]
pub fn completion_text(&self) -> Option<String> {
self.selected().map(|m| format!("{} ", m.info.name))
}
}
fn fuzzy_score(needle: &str, haystack: &str) -> Option<(i64, Vec<usize>)> {
if needle.is_empty() {
return Some((0, Vec::new()));
}
let hay: Vec<char> = haystack.chars().map(|c| c.to_ascii_lowercase()).collect();
let pat: Vec<char> = needle.chars().collect();
let mut matched: Vec<usize> = Vec::with_capacity(pat.len());
let mut hi = 0usize;
for &p in &pat {
let mut found = None;
while hi < hay.len() {
if hay[hi] == p {
found = Some(hi);
hi += 1;
break;
}
hi += 1;
}
matched.push(found?);
}
let first = i64::try_from(matched[0]).unwrap_or(i64::MAX);
let contiguous: i64 = matched
.windows(2)
.filter(|pair| pair[1] == pair[0] + 1)
.count()
.try_into()
.unwrap_or(i64::MAX);
let prefix_bonus: i64 = if first == 0 { 50 } else { 0 };
let score = prefix_bonus + contiguous * 10 - first;
Some((score, matched))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_slash_means_no_picker() {
assert!(SlashPicker::from_prompt_line("hello").is_none());
assert!(SlashPicker::from_prompt_line("").is_none());
}
#[test]
fn empty_filter_lists_every_entry_in_catalog_order() {
let p = SlashPicker::from_prompt_line("/").expect("picker");
assert_eq!(p.matches().len(), COMMAND_CATALOG.len());
for (i, m) in p.matches().iter().enumerate() {
assert_eq!(m.info.name, COMMAND_CATALOG[i].name);
}
}
#[test]
fn filter_narrows_and_orders_by_prefix_match() {
let p = SlashPicker::from_prompt_line("/st").expect("picker");
let names: Vec<&str> = p.matches().iter().map(|m| m.info.name).collect();
assert!(names.contains(&"/status"), "want /status in {names:?}");
assert!(names.contains(&"/state"), "want /state in {names:?}");
assert_eq!(names[0], "/status", "catalog ordering should be preserved");
}
#[test]
fn fuzzy_subsequence_match() {
let p = SlashPicker::from_prompt_line("/pe").expect("picker");
let names: Vec<&str> = p.matches().iter().map(|m| m.info.name).collect();
assert!(names.contains(&"/pause-entries"));
assert!(!names.contains(&"/pos"));
}
#[test]
fn selection_wraps_in_both_directions() {
let mut p = SlashPicker::from_prompt_line("/st").expect("picker");
let len = p.matches().len();
assert!(len >= 2);
let orig = p.selected_index();
for _ in 0..len {
p.select_next();
}
assert_eq!(p.selected_index(), orig, "next wraps at len");
p.select_prev();
assert_eq!(p.selected_index(), (orig + len - 1) % len);
}
#[test]
fn completion_text_appends_trailing_space() {
let p = SlashPicker::from_prompt_line("/he").expect("picker");
let comp = p.completion_text().expect("selected");
assert!(comp.ends_with(' '));
assert!(comp.trim_end().starts_with('/'));
}
#[test]
fn filter_stops_at_first_space() {
let p = SlashPicker::from_prompt_line("/regime BTC").expect("picker");
let names: Vec<&str> = p.matches().iter().map(|m| m.info.name).collect();
assert_eq!(names[0], "/regime");
}
#[test]
fn matched_char_indices_point_into_name() {
let p = SlashPicker::from_prompt_line("/he").expect("picker");
let first = &p.matches()[0];
for &i in &first.matched_chars {
assert!(
i > 0 && i < first.info.name.chars().count(),
"index {i} out of bounds for {}",
first.info.name
);
}
}
}