use crate::browser::Entry;
use crate::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
#[derive(Debug, Clone, PartialEq)]
pub struct Match {
pub index: usize,
pub score: i32,
pub positions: Vec<usize>,
}
pub struct FindPrompt {
pub query: String,
}
impl FindPrompt {
pub fn open() -> Self {
Self {
query: String::new(),
}
}
}
const MATCH: i32 = 16;
const BONUS_BOUNDARY: i32 = 8;
const BONUS_CONSECUTIVE: i32 = 8;
const PENALTY_GAP_START: i32 = -3;
const PENALTY_GAP_EXTEND: i32 = -1;
pub fn rank(entries: &[Entry], query: &str) -> Vec<Match> {
if query.is_empty() {
return (0..entries.len())
.map(|index| Match {
index,
score: 0,
positions: Vec::new(),
})
.collect();
}
let case_sensitive = query.chars().any(char::is_uppercase);
let needle: Vec<char> = query.chars().map(|c| fold(c, case_sensitive)).collect();
let mut matches: Vec<Match> = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let haystack: Vec<char> = entry.name.chars().collect();
best_match(&haystack, &needle, case_sensitive).map(|(score, positions)| Match {
index,
score,
positions,
})
})
.collect();
matches.sort_by(|a, b| {
b.score.cmp(&a.score).then_with(|| {
entries[a.index]
.name
.len()
.cmp(&entries[b.index].name.len())
})
});
matches
}
fn fold(c: char, case_sensitive: bool) -> char {
if case_sensitive {
c
} else {
c.to_lowercase().next().unwrap_or(c)
}
}
fn best_match(
haystack: &[char],
needle: &[char],
case_sensitive: bool,
) -> Option<(i32, Vec<usize>)> {
let first = *needle.first()?;
let mut best: Option<(i32, Vec<usize>)> = None;
for anchor in 0..haystack.len() {
if fold(haystack[anchor], case_sensitive) != first {
continue;
}
if let Some(candidate) = greedy_from(haystack, needle, anchor, case_sensitive) {
if best
.as_ref()
.map_or(true, |(score, _)| candidate.0 > *score)
{
best = Some(candidate);
}
}
}
best
}
fn greedy_from(
haystack: &[char],
needle: &[char],
anchor: usize,
case_sensitive: bool,
) -> Option<(i32, Vec<usize>)> {
let mut positions = Vec::with_capacity(needle.len());
let mut score = 0;
let mut previous = anchor;
for (n, &want) in needle.iter().enumerate() {
let at = if n == 0 {
anchor } else {
(previous + 1..haystack.len()).find(|&i| fold(haystack[i], case_sensitive) == want)?
};
score += MATCH;
if is_boundary(haystack, at) {
score += BONUS_BOUNDARY;
}
if n > 0 {
match at - previous - 1 {
0 => score += BONUS_CONSECUTIVE,
gap => score += PENALTY_GAP_START + PENALTY_GAP_EXTEND * (gap as i32 - 1),
}
}
positions.push(at);
previous = at;
}
Some((score, positions))
}
fn is_boundary(haystack: &[char], i: usize) -> bool {
if i == 0 {
return true;
}
let previous = haystack[i - 1];
matches!(previous, '_' | '-' | '.' | '/' | ' ')
|| (!previous.is_uppercase() && haystack[i].is_uppercase())
}
pub fn prompt_line(
prompt: &FindPrompt,
shown: usize,
total: usize,
theme: &Theme,
) -> Line<'static> {
let (text, bg) = if shown == 0 && !prompt.query.is_empty() {
(
format!(" / {}_ — no matches Esc cancel ", prompt.query),
theme.warn,
)
} else {
(
format!(
" / {}_ {}/{} Enter open · ↑↓ move · Esc cancel ",
prompt.query, shown, total
),
theme.info,
)
};
Line::from(Span::styled(
text,
Style::default()
.bg(bg)
.fg(theme.bg)
.add_modifier(Modifier::BOLD),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::classify;
fn entries(names: &[&str]) -> Vec<Entry> {
names
.iter()
.map(|name| Entry {
kind: classify(name),
name: name.to_string(),
path: format!("/test/{}", name),
})
.collect()
}
fn ranked(names: &[&str], query: &str) -> Vec<String> {
let entries = entries(names);
rank(&entries, query)
.iter()
.map(|m| entries[m.index].name.clone())
.collect()
}
#[test]
fn empty_query_is_the_identity() {
let names = ["b.csv", "a.csv", "sub"];
let matched = rank(&entries(&names), "");
assert_eq!(
matched.iter().map(|m| m.index).collect::<Vec<_>>(),
vec![0, 1, 2],
"an empty query must not reorder the listing"
);
assert!(matched.iter().all(|m| m.positions.is_empty()));
}
#[test]
fn a_query_that_matches_nothing_ranks_nothing() {
assert!(ranked(&["orders.csv", "sales.csv"], "zzz").is_empty());
}
#[test]
fn matching_is_a_subsequence_not_a_substring() {
assert_eq!(ranked(&["orders.csv"], "ors"), vec!["orders.csv"]);
}
#[test]
fn matching_ignores_case_for_a_lowercase_query() {
assert_eq!(ranked(&["Orders.CSV"], "orders"), vec!["Orders.CSV"]);
}
#[test]
fn an_uppercase_char_makes_the_whole_query_case_sensitive() {
assert_eq!(ranked(&["Sales.csv", "sales.csv"], "S"), vec!["Sales.csv"]);
assert_eq!(ranked(&["Sales.csv", "sales.csv"], "s").len(), 2);
}
#[test]
fn the_tightest_run_wins_over_the_earliest_one() {
let matched = rank(&entries(&["a_xb_ab"]), "ab");
assert_eq!(matched[0].positions, vec![5, 6]);
}
#[test]
fn a_match_at_a_word_boundary_outranks_one_mid_word() {
assert_eq!(
ranked(&["discount.csv", "sales_count.csv"], "sc"),
vec!["sales_count.csv", "discount.csv"]
);
}
#[test]
fn a_consecutive_run_outranks_a_scattered_match() {
assert_eq!(
ranked(&["wholesale.csv", "sales.csv"], "sal"),
vec!["sales.csv", "wholesale.csv"]
);
}
#[test]
fn a_camel_case_edge_counts_as_a_boundary() {
assert!(is_boundary(&"salesCount".chars().collect::<Vec<_>>(), 5));
}
#[test]
fn a_shorter_name_wins_an_equal_score() {
assert_eq!(
ranked(&["sales_2024_q1.csv", "sales.csv"], "sales"),
vec!["sales.csv", "sales_2024_q1.csv"]
);
}
#[test]
fn a_full_tie_keeps_the_listing_order() {
assert_eq!(ranked(&["ab.csv", "ac.csv"], "a"), vec!["ab.csv", "ac.csv"]);
}
#[test]
fn positions_are_char_indices_not_byte_offsets() {
let matched = rank(&entries(&["héllo.csv"]), "él");
assert_eq!(matched[0].positions, vec![1, 2]);
}
#[test]
fn positions_are_ascending_and_unique() {
let matched = rank(&entries(&["orders_2024.csv"]), "ordcsv");
let positions = &matched[0].positions;
assert!(
positions.windows(2).all(|w| w[0] < w[1]),
"positions must be strictly ascending: {:?}",
positions
);
}
#[test]
fn every_query_char_gets_a_position() {
let matched = rank(&entries(&["sales_count.csv"]), "sc");
assert_eq!(matched[0].positions.len(), 2);
}
fn prompt_text(prompt: &FindPrompt, shown: usize, total: usize) -> String {
prompt_line(prompt, shown, total, crate::theme::default_theme())
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn prompt_line_shows_the_query_and_the_match_count() {
let prompt = FindPrompt {
query: "sal".to_string(),
};
let text = prompt_text(&prompt, 3, 47);
assert!(text.contains("sal"), "missing query: {}", text);
assert!(text.contains("3/47"), "missing count: {}", text);
assert!(text.contains("Esc cancel"), "missing exit hint: {}", text);
}
#[test]
fn prompt_line_flags_a_query_that_matches_nothing() {
let prompt = FindPrompt {
query: "zzz".to_string(),
};
let text = prompt_text(&prompt, 0, 47);
assert!(text.contains("no matches"), "unexpected line: {}", text);
}
#[test]
fn prompt_line_does_not_cry_no_matches_before_anything_is_typed() {
let text = prompt_text(&FindPrompt::open(), 0, 0);
assert!(!text.contains("no matches"), "unexpected line: {}", text);
}
}