use std::cmp::Reverse;
#[derive(Debug, Clone, Copy)]
pub struct Entry<'a> {
pub id: &'a str,
pub title: &'a str,
pub keywords: &'a str,
pub category: &'a str,
}
const BASE: i32 = 16;
const WORD_START: i32 = 20;
const CONSECUTIVE: i32 = 18;
const GAP: i32 = 2;
const GAP_CAP: usize = 6;
const LEADING: i32 = 1;
const LEADING_CAP: usize = 8;
const RUN_BONUS: i32 = 30;
const RUN_AT_WORD_START: i32 = 40;
const RUN_AT_START: i32 = 20;
const TITLE_WEIGHT: i32 = 10;
const KEYWORD_WEIGHT: i32 = 6;
const CATEGORY_WEIGHT: i32 = 5;
pub const RECENT_LIMIT: usize = 8;
fn fold(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn score_word_in(word: &[char], field: &str) -> Option<i32> {
let hay: Vec<char> = field.chars().map(fold).collect();
let (m, n) = (word.len(), hay.len());
if m == 0 || m > n {
return None;
}
let starts_word =
|j: usize| -> bool { j == 0 || hay.get(j - 1).is_none_or(|prev| !prev.is_alphanumeric()) };
let mut best: Vec<Option<i32>> = vec![None; n];
for (i, &letter) in word.iter().enumerate() {
let mut next: Vec<Option<i32>> = vec![None; n];
for j in i..n {
if hay[j] != letter {
continue;
}
let mut here = BASE + if starts_word(j) { WORD_START } else { 0 };
let before = if i == 0 {
let skipped = i32::try_from(j.min(LEADING_CAP)).unwrap_or(0);
Some(-LEADING * skipped)
} else {
(i - 1..j)
.filter_map(|k| {
let prior = best[k]?;
let step = if k + 1 == j {
CONSECUTIVE
} else {
let skipped = i32::try_from((j - k - 1).min(GAP_CAP)).unwrap_or(0);
-GAP * skipped
};
Some(prior + step)
})
.max()
};
if let Some(before) = before {
here += before;
next[j] = Some(here);
}
}
best = next;
}
let mut score = best.into_iter().flatten().max()?;
if let Some(at) = hay.windows(m).position(|window| window == word) {
score += RUN_BONUS;
if starts_word(at) {
score += RUN_AT_WORD_START;
}
if at == 0 {
score += RUN_AT_START;
}
}
Some(score)
}
fn score_word(word: &[char], entry: &Entry<'_>) -> Option<i32> {
let title = score_word_in(word, entry.title).map(|score| {
let length = i32::try_from(entry.title.chars().count()).unwrap_or(0);
(score - length / 2) * TITLE_WEIGHT
});
let keywords = score_word_in(word, entry.keywords).map(|score| score * KEYWORD_WEIGHT);
let category = score_word_in(word, entry.category).map(|score| score * CATEGORY_WEIGHT);
[title, keywords, category].into_iter().flatten().max()
}
#[must_use]
pub fn score(query: &str, entry: &Entry<'_>) -> Option<i32> {
let words: Vec<Vec<char>> = query
.split_whitespace()
.map(|word| word.chars().map(fold).collect())
.collect();
if words.is_empty() {
return None;
}
words
.iter()
.map(|word| score_word(word, entry))
.sum::<Option<i32>>()
}
#[must_use]
pub fn rank(query: &str, entries: &[Entry<'_>], recent: &[&str]) -> Vec<usize> {
if query.split_whitespace().next().is_none() {
let mut order: Vec<usize> = recent
.iter()
.filter_map(|id| entries.iter().position(|entry| entry.id == *id))
.collect();
let mut rest: Vec<usize> = (0..entries.len())
.filter(|index| !order.contains(index))
.collect();
rest.sort_by(|&a, &b| {
(entries[a].category, entries[a].title).cmp(&(entries[b].category, entries[b].title))
});
order.extend(rest);
return order;
}
let mut scored: Vec<(usize, i32)> = entries
.iter()
.enumerate()
.filter_map(|(index, entry)| Some((index, score(query, entry)?)))
.collect();
scored.sort_by_key(|&(_, points)| Reverse(points));
scored.into_iter().map(|(index, _)| index).collect()
}
#[cfg(test)]
mod tests {
use super::*;
const fn entry<'a>(
id: &'a str,
title: &'a str,
category: &'a str,
keywords: &'a str,
) -> Entry<'a> {
Entry {
id,
title,
keywords,
category,
}
}
fn sample() -> Vec<Entry<'static>> {
vec![
entry("file.save", "Save", "File", "write store"),
entry("file.save_as", "Save As...", "File", "write copy"),
entry("file.new", "New Design...", "File", "create blank"),
entry(
"file.export_png",
"Export Diagram (PNG)...",
"File",
"picture",
),
entry("file.export_asc", "Export Edited .asc", "File", "text"),
entry("edit.undo", "Undo", "Edit", "history revert"),
entry("edit.redo", "Redo", "Edit", "undo again"),
entry("tiers.delete", "Delete Selected Tier", "Tiers", "remove"),
entry("tiers.add_concave", "Add Concave Tier", "Tiers", "tool cut"),
entry("solve.run", "Solve", "Solve", "calculate depths"),
]
}
fn titles(entries: &[Entry<'_>], order: &[usize]) -> Vec<String> {
order
.iter()
.map(|&i| entries[i].title.to_string())
.collect()
}
#[test]
fn a_word_must_match_as_a_subsequence() {
let entries = sample();
assert!(score("save", &entries[0]).is_some());
assert!(score("sve", &entries[0]).is_some());
assert!(score("saved", &entries[0]).is_none());
assert!(score("zzz", &entries[0]).is_none());
}
#[test]
fn matching_ignores_case() {
let entries = sample();
assert_eq!(score("SAVE", &entries[0]), score("save", &entries[0]));
assert_eq!(score("sAvE", &entries[0]), score("save", &entries[0]));
}
#[test]
fn an_exact_short_title_ranks_above_a_longer_one() {
let entries = sample();
let order = rank("save", &entries, &[]);
let names = titles(&entries, &order);
assert_eq!(names[0], "Save");
assert_eq!(names[1], "Save As...");
}
#[test]
fn a_title_match_outranks_a_keyword_match() {
let entries = sample();
let order = rank("undo", &entries, &[]);
let names = titles(&entries, &order);
assert_eq!(names, vec!["Undo", "Redo"]);
}
#[test]
fn letters_in_one_run_beat_letters_scattered_over_the_title() {
let entries = [
entry("a", "Add Concave Tier", "Tiers", ""),
entry("b", "Delete Selected Tier", "Tiers", ""),
];
let order = rank("de", &entries, &[]);
let names = titles(&entries, &order);
assert_eq!(names[0], "Delete Selected Tier");
assert_eq!(names.len(), 2);
}
#[test]
fn word_starts_beat_the_middle_of_a_word() {
let entries = [
entry("a", "Border", "Edit", ""),
entry("b", "Open Document", "File", ""),
];
let order = rank("od", &entries, &[]);
assert_eq!(titles(&entries, &order), vec!["Open Document", "Border"]);
}
#[test]
fn every_word_of_a_query_has_to_match() {
let entries = sample();
let order = rank("export png", &entries, &[]);
assert_eq!(titles(&entries, &order), vec!["Export Diagram (PNG)..."]);
assert_eq!(rank("export zzz", &entries, &[]), Vec::<usize>::new());
}
#[test]
fn the_category_and_the_keywords_are_searched_too() {
let entries = sample();
let by_category = rank("tiers", &entries, &[]);
assert_eq!(
titles(&entries, &by_category),
vec!["Delete Selected Tier", "Add Concave Tier"]
);
let by_keyword = rank("revert", &entries, &[]);
assert_eq!(titles(&entries, &by_keyword), vec!["Undo"]);
}
#[test]
fn equal_scores_keep_the_table_order() {
let entries = [
entry("a", "Alpha", "X", ""),
entry("b", "Alpha", "X", ""),
entry("c", "Alpha", "X", ""),
];
assert_eq!(rank("alpha", &entries, &[]), vec![0, 1, 2]);
}
#[test]
fn an_empty_query_lists_recent_commands_then_categories_alphabetically() {
let entries = sample();
let order = rank("", &entries, &["solve.run", "file.new"]);
let names = titles(&entries, &order);
assert_eq!(&names[..2], ["Solve", "New Design..."]);
assert_eq!(
&names[2..],
[
"Redo",
"Undo",
"Export Diagram (PNG)...",
"Export Edited .asc",
"Save",
"Save As...",
"Add Concave Tier",
"Delete Selected Tier",
]
);
assert_eq!(order.len(), entries.len());
}
#[test]
fn a_recent_id_that_no_longer_exists_is_skipped() {
let entries = sample();
let order = rank(" ", &entries, &["gone.away", "edit.undo"]);
assert_eq!(entries[order[0]].id, "edit.undo");
assert_eq!(order.len(), entries.len());
}
#[test]
fn a_search_ignores_the_recent_list() {
let entries = sample();
let with_recent = rank("save", &entries, &["solve.run"]);
assert_eq!(with_recent, rank("save", &entries, &[]));
}
#[test]
fn an_empty_word_list_has_no_score() {
let entries = sample();
assert_eq!(score("", &entries[0]), None);
assert_eq!(score(" ", &entries[0]), None);
}
}