use crate::{sanitize_str, sanitize_title};
use std::cmp::Ordering;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum KeyModifier {
Ctrl,
Shift,
Alt,
Meta,
}
impl KeyModifier {
pub const fn label(self) -> &'static str {
match self {
Self::Ctrl => "Ctrl",
Self::Shift => "Shift",
Self::Alt => "Alt",
Self::Meta => "Meta",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Shortcut {
pub modifiers: Vec<KeyModifier>,
pub key: String,
}
impl Shortcut {
pub fn new(key: impl Into<String>) -> Self {
Self {
modifiers: Vec::new(),
key: sanitize_title(&key.into(), 24),
}
}
pub fn with_modifiers(mut self, modifiers: impl IntoIterator<Item = KeyModifier>) -> Self {
self.modifiers = modifiers.into_iter().collect();
self
}
pub fn label(&self) -> String {
self.modifiers
.iter()
.map(|modifier| modifier.label())
.chain(std::iter::once(self.key.as_str()))
.collect::<Vec<_>>()
.join("+")
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CommandItem {
pub id: String,
pub title: String,
pub subtitle: Option<String>,
pub keywords: Vec<String>,
pub shortcut: Option<Shortcut>,
pub disabled: bool,
pub score_bias: f32,
}
impl CommandItem {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: sanitize_key(&id.into()),
title: sanitize_title(&title.into(), 80),
subtitle: None,
keywords: Vec::new(),
shortcut: None,
disabled: false,
score_bias: 0.0,
}
}
pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(sanitize_str(&subtitle.into(), 120));
self
}
pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.keywords = keywords
.into_iter()
.map(|keyword| sanitize_key(&keyword.into()))
.filter(|keyword| !keyword.is_empty())
.collect();
self
}
pub fn with_shortcut(mut self, shortcut: Shortcut) -> Self {
self.shortcut = Some(shortcut);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn with_score_bias(mut self, score_bias: f32) -> Self {
self.score_bias = sanitize_score_bias(score_bias);
self
}
pub fn score(&self, query: &str) -> Option<f32> {
let query = normalize_query(query);
let tokens = query_tokens(&query);
self.score_tokens(&tokens)
}
fn score_tokens(&self, tokens: &[&str]) -> Option<f32> {
if self.disabled {
return None;
}
let score_bias = sanitize_score_bias(self.score_bias);
if tokens.is_empty() {
return Some(1.0 + score_bias);
}
let subtitle = self.subtitle.as_deref().unwrap_or_default();
let mut total = 0.0;
for token in tokens {
let mut score = None;
score = max_score(score, match_rank(&self.id, token, 100.0));
score = max_score(score, match_rank(&self.title, token, 90.0));
score = max_score(score, match_rank(subtitle, token, 40.0));
for keyword in &self.keywords {
score = max_score(score, match_rank(keyword, token, 65.0));
}
total += score?;
}
Some(total / tokens.len() as f32 + score_bias)
}
}
fn sanitize_score_bias(score_bias: f32) -> f32 {
if score_bias.is_finite() {
score_bias
} else {
0.0
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CommandPalette {
pub query: String,
pub selected_index: usize,
pub items: Vec<CommandItem>,
}
impl CommandPalette {
pub fn new(items: impl IntoIterator<Item = CommandItem>) -> Self {
Self {
query: String::new(),
selected_index: 0,
items: items.into_iter().collect(),
}
}
pub fn set_query(&mut self, query: impl Into<String>) {
self.query = sanitize_str(&query.into(), 120);
self.selected_index = 0;
}
pub fn move_selection(&mut self, delta: isize) {
let count = self.filtered_count();
if count == 0 {
self.selected_index = 0;
return;
}
let current = self.selected_index.min(count - 1);
self.selected_index = if delta.is_negative() {
current.saturating_sub(delta.unsigned_abs())
} else {
current.saturating_add(delta as usize).min(count - 1)
};
}
pub fn move_selection_wrapping(&mut self, delta: isize) {
let count = self.filtered_count();
if count == 0 {
self.selected_index = 0;
return;
}
let current = self.selected_index.min(count - 1) as i128;
let count = count as i128;
self.selected_index = (current + delta as i128).rem_euclid(count) as usize;
}
pub fn filtered_items(&self) -> Vec<&CommandItem> {
self.filtered_items_limited(usize::MAX)
}
pub fn filtered_items_limited(&self, limit: usize) -> Vec<&CommandItem> {
let limit = limit.min(self.items.len());
if limit == 0 {
return Vec::new();
}
let query = normalize_query(&self.query);
let tokens = query_tokens(&query);
self.filtered_items_for_tokens(&tokens, limit)
}
pub fn filtered_count(&self) -> usize {
let query = normalize_query(&self.query);
let tokens = query_tokens(&query);
self.items
.iter()
.filter(|item| item.score_tokens(&tokens).is_some())
.count()
}
fn filtered_items_for_tokens(&self, tokens: &[&str], limit: usize) -> Vec<&CommandItem> {
if limit >= self.items.len() {
let mut matches = self
.items
.iter()
.enumerate()
.filter_map(|(index, item)| {
item.score_tokens(tokens)
.map(|score| CommandMatch { score, index, item })
})
.collect::<Vec<_>>();
matches.sort_by(compare_command_matches);
return matches.into_iter().map(|matched| matched.item).collect();
}
let mut matches = Vec::with_capacity(limit);
for (index, item) in self.items.iter().enumerate() {
if let Some(score) = item.score_tokens(tokens) {
insert_command_match(&mut matches, limit, CommandMatch { score, index, item });
}
}
matches.into_iter().map(|matched| matched.item).collect()
}
pub fn selected_item(&self) -> Option<&CommandItem> {
let count = self.filtered_count();
if count == 0 {
return None;
}
let selected = self.selected_index.min(count - 1);
self.filtered_items_limited(selected.saturating_add(1))
.get(selected)
.copied()
}
}
#[derive(Clone, Copy)]
struct CommandMatch<'a> {
score: f32,
index: usize,
item: &'a CommandItem,
}
fn insert_command_match<'a>(
matches: &mut Vec<CommandMatch<'a>>,
limit: usize,
item: CommandMatch<'a>,
) {
let position = matches
.iter()
.position(|existing| compare_command_matches(&item, existing).is_lt())
.unwrap_or(matches.len());
if position >= limit {
return;
}
matches.insert(position, item);
if matches.len() > limit {
matches.pop();
}
}
fn compare_command_matches(left: &CommandMatch<'_>, right: &CommandMatch<'_>) -> Ordering {
right
.score
.total_cmp(&left.score)
.then_with(|| left.item.title.cmp(&right.item.title))
.then_with(|| left.index.cmp(&right.index))
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextSnippet {
pub trigger: String,
pub body: String,
pub description: Option<String>,
pub keywords: Vec<String>,
}
impl TextSnippet {
pub fn new(trigger: impl Into<String>, body: impl Into<String>) -> Self {
Self {
trigger: sanitize_key(&trigger.into()),
body: sanitize_str(&body.into(), 4_000),
description: None,
keywords: Vec::new(),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(sanitize_str(&description.into(), 120));
self
}
pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.keywords = keywords
.into_iter()
.map(|keyword| sanitize_key(&keyword.into()))
.filter(|keyword| !keyword.is_empty())
.collect();
self
}
pub fn score(&self, query: &str) -> Option<f32> {
let query = normalize_query(query);
let tokens = query_tokens(&query);
self.score_tokens(&tokens)
}
fn score_tokens(&self, tokens: &[&str]) -> Option<f32> {
if tokens.is_empty() {
return Some(1.0);
}
let description = self.description.as_deref().unwrap_or_default();
let mut total = 0.0;
for token in tokens {
let mut score = None;
score = max_score(score, match_rank(&self.trigger, token, 100.0));
score = max_score(score, match_rank(description, token, 45.0));
for keyword in &self.keywords {
score = max_score(score, match_rank(keyword, token, 65.0));
}
total += score?;
}
Some(total / tokens.len() as f32)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SnippetSet {
pub snippets: Vec<TextSnippet>,
}
impl SnippetSet {
pub fn new(snippets: impl IntoIterator<Item = TextSnippet>) -> Self {
Self {
snippets: snippets.into_iter().map(sanitize_snippet).collect(),
}
}
pub fn expand(&self, trigger: &str) -> Option<&str> {
let trigger = sanitize_key(trigger);
self.snippets
.iter()
.find(|snippet| snippet.trigger == trigger)
.map(|snippet| snippet.body.as_str())
}
pub fn matches(&self, query: &str) -> Vec<&TextSnippet> {
self.matches_limited(query, usize::MAX)
}
pub fn matches_limited(&self, query: &str, limit: usize) -> Vec<&TextSnippet> {
let limit = limit.min(self.snippets.len());
if limit == 0 {
return Vec::new();
}
let query = normalize_query(query);
let tokens = query_tokens(&query);
if limit >= self.snippets.len() {
let mut matches = self
.snippets
.iter()
.enumerate()
.filter_map(|(index, snippet)| {
snippet.score_tokens(&tokens).map(|score| SnippetMatch {
score,
index,
snippet,
})
})
.collect::<Vec<_>>();
matches.sort_by(compare_snippet_matches);
return matches.into_iter().map(|matched| matched.snippet).collect();
}
let mut matches = Vec::with_capacity(limit);
for (index, snippet) in self.snippets.iter().enumerate() {
if let Some(score) = snippet.score_tokens(&tokens) {
insert_snippet_match(
&mut matches,
limit,
SnippetMatch {
score,
index,
snippet,
},
);
}
}
matches.into_iter().map(|matched| matched.snippet).collect()
}
}
#[derive(Clone, Copy)]
struct SnippetMatch<'a> {
score: f32,
index: usize,
snippet: &'a TextSnippet,
}
fn insert_snippet_match<'a>(
matches: &mut Vec<SnippetMatch<'a>>,
limit: usize,
item: SnippetMatch<'a>,
) {
let position = matches
.iter()
.position(|existing| compare_snippet_matches(&item, existing).is_lt())
.unwrap_or(matches.len());
if position >= limit {
return;
}
matches.insert(position, item);
if matches.len() > limit {
matches.pop();
}
}
fn compare_snippet_matches(left: &SnippetMatch<'_>, right: &SnippetMatch<'_>) -> Ordering {
right
.score
.total_cmp(&left.score)
.then_with(|| left.snippet.trigger.cmp(&right.snippet.trigger))
.then_with(|| left.index.cmp(&right.index))
}
fn sanitize_snippet(mut snippet: TextSnippet) -> TextSnippet {
snippet.trigger = sanitize_key(&snippet.trigger);
snippet.body = sanitize_str(&snippet.body, 4_000);
snippet.description = snippet
.description
.map(|description| sanitize_str(&description, 120))
.filter(|description| !description.is_empty());
snippet.keywords = snippet
.keywords
.into_iter()
.map(|keyword| sanitize_key(&keyword))
.filter(|keyword| !keyword.is_empty())
.collect();
snippet
}
fn sanitize_key(input: &str) -> String {
sanitize_str(input, 80)
.trim()
.to_lowercase()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
.collect()
}
fn normalize_query(input: &str) -> String {
sanitize_str(input, 120).trim().to_lowercase()
}
fn query_tokens(query: &str) -> Vec<&str> {
query.split_whitespace().collect()
}
fn match_rank(candidate: &str, query: &str, base_score: f32) -> Option<f32> {
if candidate.is_ascii() && query.is_ascii() {
return match_rank_ascii(candidate, query, base_score);
}
let candidate = candidate.to_lowercase();
match_rank_normalized(&candidate, query, base_score)
}
fn match_rank_normalized(candidate: &str, query: &str, base_score: f32) -> Option<f32> {
if candidate == query {
Some(base_score + 30.0)
} else if candidate.starts_with(query) {
Some(base_score + 15.0)
} else if candidate.contains(query) {
Some(base_score)
} else {
None
}
}
fn match_rank_ascii(candidate: &str, query: &str, base_score: f32) -> Option<f32> {
if candidate.eq_ignore_ascii_case(query) {
Some(base_score + 30.0)
} else if candidate.len() >= query.len() && candidate[..query.len()].eq_ignore_ascii_case(query)
{
Some(base_score + 15.0)
} else if contains_ascii_case_insensitive(candidate, query) {
Some(base_score)
} else {
None
}
}
fn contains_ascii_case_insensitive(candidate: &str, query: &str) -> bool {
let query = query.as_bytes();
if query.is_empty() {
return true;
}
candidate.as_bytes().windows(query.len()).any(|window| {
window
.iter()
.zip(query.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right))
})
}
fn max_score(left: Option<f32>, right: Option<f32>) -> Option<f32> {
match (left, right) {
(Some(left), Some(right)) => Some(left.max(right)),
(Some(value), None) | (None, Some(value)) => Some(value),
(None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shortcut_label_joins_modifiers() {
let shortcut = Shortcut::new("P").with_modifiers([KeyModifier::Ctrl, KeyModifier::Shift]);
assert_eq!(shortcut.label(), "Ctrl+Shift+P");
}
#[test]
fn command_palette_filters_and_skips_disabled_items() {
let mut palette = CommandPalette::new([
CommandItem::new("open", "Open File"),
CommandItem::new("close", "Close File").disabled(true),
CommandItem::new("theme", "Switch Theme").with_keywords(["palette"]),
]);
palette.set_query("pal");
let matches = palette.filtered_items();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id, "theme");
assert_eq!(palette.selected_item().unwrap().title, "Switch Theme");
}
#[test]
fn command_palette_matches_tokens_across_fields() {
let mut palette = CommandPalette::new([
CommandItem::new("copy.safe", "Copy Safe Text").with_keywords(["clipboard"]),
CommandItem::new("theme.midnight", "Switch Theme").with_keywords(["palette"]),
]);
palette.set_query("theme pal");
let matches = palette.filtered_items();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id, "theme.midnight");
}
#[test]
fn command_palette_clamps_stale_selection() {
let mut palette = CommandPalette::new([
CommandItem::new("open", "Open File"),
CommandItem::new("theme", "Switch Theme"),
]);
palette.selected_index = 99;
assert_eq!(palette.selected_item().unwrap().id, "theme");
palette.set_query("open");
palette.selected_index = 99;
assert_eq!(palette.selected_item().unwrap().id, "open");
}
#[test]
fn command_palette_sanitizes_non_finite_score_bias() {
let item = CommandItem::new("open", "Open File").with_score_bias(f32::NAN);
assert_eq!(item.score_bias, 0.0);
let mut malformed = CommandItem::new("theme", "Theme");
malformed.score_bias = f32::NAN;
assert_eq!(malformed.score("theme"), Some(130.0));
let palette = CommandPalette::new([malformed, CommandItem::new("open", "Open")]);
assert_eq!(palette.filtered_items()[0].id, "open");
}
#[test]
fn command_palette_can_wrap_selection() {
let mut palette = CommandPalette::new([
CommandItem::new("a", "Alpha"),
CommandItem::new("b", "Beta"),
CommandItem::new("c", "Gamma"),
]);
palette.move_selection_wrapping(-1);
assert_eq!(palette.selected_item().unwrap().id, "c");
palette.move_selection_wrapping(1);
assert_eq!(palette.selected_item().unwrap().id, "a");
}
#[test]
fn command_palette_wrapping_handles_extreme_deltas() {
let mut palette = CommandPalette::new([
CommandItem::new("a", "Alpha"),
CommandItem::new("b", "Beta"),
CommandItem::new("c", "Gamma"),
]);
palette.selected_index = 1;
palette.move_selection_wrapping(isize::MAX);
assert!(palette.selected_index < 3);
palette.move_selection_wrapping(isize::MIN);
assert!(palette.selected_index < 3);
}
#[test]
fn command_palette_limited_results_keep_top_ranked_window() {
let mut palette = CommandPalette::new((0..512).map(|index| {
CommandItem::new(format!("cmd.{index:03}"), format!("Command {index:03}"))
.with_keywords(["bulk"])
.with_score_bias(index as f32)
}));
palette.set_query("bulk");
let matches = palette.filtered_items_limited(5);
assert_eq!(palette.filtered_count(), 512);
assert_eq!(matches.len(), 5);
assert_eq!(matches[0].id, "cmd.511");
assert_eq!(matches[4].id, "cmd.507");
}
#[test]
fn command_palette_matches_public_uppercase_fields() {
let mut palette = CommandPalette::new([CommandItem {
id: "OPEN.FILE".to_string(),
title: "Open Workspace File".to_string(),
subtitle: Some("Clipboard Target".to_string()),
keywords: vec!["QuickAction".to_string()],
shortcut: None,
disabled: false,
score_bias: 0.0,
}]);
palette.set_query("workspace quick");
let matches = palette.filtered_items();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id, "OPEN.FILE");
}
#[test]
fn snippets_expand_and_rank_matches() {
let snippets = SnippetSet::new([
TextSnippet::new("hdr", "# Title").with_keywords(["markdown"]),
TextSnippet::new("todo", "- [ ] ").with_description("Task checkbox"),
]);
assert_eq!(snippets.expand("hdr"), Some("# Title"));
assert_eq!(snippets.matches("task")[0].trigger, "todo");
}
#[test]
fn snippets_match_tokens_across_description_and_keywords() {
let snippets = SnippetSet::new([
TextSnippet::new("hdr", "# Title").with_keywords(["markdown"]),
TextSnippet::new("todo", "- [ ] ")
.with_description("Task checkbox")
.with_keywords(["markdown"]),
]);
let matches = snippets.matches("task mark");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].trigger, "todo");
}
#[test]
fn snippet_limited_results_keep_sorted_window() {
let snippets =
SnippetSet::new((0..300).map(|index| {
TextSnippet::new(format!("s{index:03}"), "body").with_keywords(["bulk"])
}));
let matches = snippets.matches_limited("bulk", 4);
assert_eq!(matches.len(), 4);
assert_eq!(matches[0].trigger, "s000");
assert_eq!(matches[3].trigger, "s003");
}
#[test]
fn snippets_match_public_uppercase_fields() {
let snippets = SnippetSet {
snippets: vec![TextSnippet {
trigger: "HDR".to_string(),
body: "# Title".to_string(),
description: Some("Markdown Heading".to_string()),
keywords: vec!["Markup".to_string()],
}],
};
let matches = snippets.matches("heading markup");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].trigger, "HDR");
}
#[test]
fn snippet_set_sanitizes_public_malformed_snippets() {
let snippets = SnippetSet::new([TextSnippet {
trigger: "HDR \x1b[31m".to_string(),
body: "safe\x1b[2J\u{200b}".to_string(),
description: Some("desc\x07".to_string()),
keywords: vec![" Mark\x1b[0m ".to_string()],
}]);
assert_eq!(snippets.expand("hdr"), Some("safe"));
assert_eq!(snippets.matches("mark")[0].trigger, "hdr");
}
}