use super::types::CompletionItem;
#[derive(Debug)]
pub(super) struct CompletionMatcher {
partial: String,
}
impl CompletionMatcher {
pub(super) fn new(partial: impl Into<String>) -> Self {
Self { partial: partial.into() }
}
pub(super) fn filter_and_score(&self, completions: Vec<CompletionItem>) -> Vec<CompletionItem> {
if self.partial.is_empty() {
return completions;
}
let mut scored: Vec<(CompletionItem, u32)> = completions
.into_iter()
.filter_map(|completion| {
self.score(&completion.label)
.map(|score| (completion, score))
})
.collect();
scored.sort_by(|(a_comp, a_score), (b_comp, b_score)| {
b_score
.cmp(a_score)
.then_with(|| a_comp.label.cmp(&b_comp.label))
});
scored.into_iter().map(|(comp, _)| comp).collect()
}
fn score(&self, label: &str) -> Option<u32> {
let label_lower = label.to_lowercase();
let partial_lower = self.partial.to_lowercase();
if label_lower.starts_with(&partial_lower) {
return Some(1000 + (label.len() - self.partial.len()) as u32);
}
if let Some(score) = smart_match(label, &partial_lower) {
return Some(500 + score);
}
if let Some(score) = fuzzy_match(&label_lower, &partial_lower) {
return Some(score);
}
None
}
}
fn smart_match(label: &str, partial_lower: &str) -> Option<u32> {
let partial_chars: Vec<char> = partial_lower.chars().collect();
let mut significant: Vec<(char, usize)> = Vec::new();
let mut prev_was_underscore = false;
for (i, ch) in label.chars().enumerate() {
if i == 0 || ch.is_uppercase() || prev_was_underscore {
significant.push((ch.to_lowercase().next()?, i));
}
prev_was_underscore = ch == '_';
}
let mut partial_idx = 0;
let mut last_match_pos = 0;
let mut gaps = 0;
for (sig_char, pos) in significant {
if partial_idx >= partial_chars.len() {
break;
}
if sig_char == partial_chars[partial_idx] {
gaps += pos.saturating_sub(last_match_pos);
last_match_pos = pos;
partial_idx += 1;
}
}
if partial_idx == partial_chars.len() {
Some(100 - gaps.min(99) as u32)
} else {
None
}
}
fn fuzzy_match(label_lower: &str, partial_lower: &str) -> Option<u32> {
let label_chars: Vec<char> = label_lower.chars().collect();
let partial_chars: Vec<char> = partial_lower.chars().collect();
let mut label_idx = 0;
let mut first_match = None;
let mut last_match = 0;
let mut gaps = 0;
for &partial_char in &partial_chars {
let found = label_chars[label_idx..]
.iter()
.position(|&c| c == partial_char)?;
let match_pos = label_idx + found;
if first_match.is_none() {
first_match = Some(match_pos);
}
gaps += found;
last_match = match_pos;
label_idx = match_pos + 1;
}
let first = first_match?;
let spread = last_match - first;
let score = 100_u32
.saturating_sub(first as u32) .saturating_sub(spread as u32 / 2) .saturating_sub(gaps as u32 / 3);
Some(score.max(1)) }
#[cfg(test)]
mod tests {
use super::*;
use crate::dsl::completions::types::CompletionKind;
#[test]
fn test_matcher_prefix_matching() {
let matcher = CompletionMatcher::new("fade");
let completions = vec![
CompletionItem {
label: "fade_to".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("fade_to($0)".to_string()),
description: None,
},
CompletionItem {
label: "fade_from".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("fade_from($0)".to_string()),
description: None,
},
CompletionItem {
label: "dissolve".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("dissolve($0)".to_string()),
description: None,
},
];
let filtered = matcher.filter_and_score(completions);
assert_eq!(filtered.len(), 2);
assert!(filtered
.iter()
.all(|c| c.label.starts_with("fade")));
}
#[test]
fn test_matcher_smart_matching_acronym() {
let matcher = CompletionMatcher::new("EIO");
let score_expo = matcher.score("ExpoInOut");
let score_elastic = matcher.score("ElasticInOut");
assert!(score_expo.is_some(), "ExpoInOut should match EIO");
assert!(score_elastic.is_some(), "ElasticInOut should match EIO");
assert!(score_expo.unwrap() >= 500);
assert!(score_elastic.unwrap() >= 500);
}
#[test]
fn test_matcher_smart_matching_snake_case() {
let matcher = CompletionMatcher::new("sc");
let score = matcher.score("snake_case");
assert!(score.is_some(), "snake_case should match sc");
assert!(score.unwrap() >= 500, "Should be smart match score");
}
#[test]
fn test_matcher_fuzzy_matching() {
let matcher = CompletionMatcher::new("dsl");
let score = matcher.score("dissolve");
assert!(score.is_some(), "dissolve should fuzzy match dsl");
assert!(score.unwrap() < 500, "Should be fuzzy match score");
}
#[test]
fn test_matcher_case_insensitive() {
let matcher = CompletionMatcher::new("BOLD");
let score_upper = matcher.score("BOLD");
let score_lower = matcher.score("bold");
let score_mixed = matcher.score("Bold");
assert_eq!(score_upper, score_lower);
assert_eq!(score_upper, score_mixed);
}
#[test]
fn test_matcher_scoring_order() {
let matcher = CompletionMatcher::new("li");
let score_prefix = matcher.score("Linear").unwrap(); let score_smart = matcher.score("LeftIn").unwrap(); let score_fuzzy = matcher.score("ElasticIn").unwrap();
assert!(
score_prefix > score_smart,
"Prefix should beat smart: {score_prefix} vs {score_smart}"
);
assert!(
score_prefix > score_fuzzy,
"Prefix should beat fuzzy: {score_prefix} vs {score_fuzzy}"
);
assert!(
score_smart > score_fuzzy,
"Smart should beat fuzzy: {score_smart} vs {score_fuzzy}"
);
}
#[test]
fn test_matcher_empty_partial() {
let matcher = CompletionMatcher::new("");
let completions = vec![
CompletionItem {
label: "a".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("a($0)".to_string()),
description: None,
},
CompletionItem {
label: "b".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("b($0)".to_string()),
description: None,
},
CompletionItem {
label: "c".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("c($0)".to_string()),
description: None,
},
];
let filtered = matcher.filter_and_score(completions.clone());
assert_eq!(filtered.len(), completions.len());
}
#[test]
fn test_matcher_no_matches() {
let matcher = CompletionMatcher::new("xyz");
let completions = vec![
CompletionItem {
label: "fade_to".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("fade_to($0)".to_string()),
description: None,
},
CompletionItem {
label: "dissolve".to_string(),
kind: CompletionKind::Function,
detail: String::new(),
insert_text: Some("dissolve($0)".to_string()),
description: None,
},
];
let filtered = matcher.filter_and_score(completions);
assert!(filtered.is_empty());
}
}