use std::path::Path;
const MAX_SUGGESTIONS: usize = 12;
#[derive(Clone, Debug)]
pub struct Snippet {
pub text: String,
pub matched_indices: Vec<usize>,
pub score: i64,
}
#[derive(Clone, Debug, Default)]
pub struct SnippetState {
pub visible: bool,
pub query: String,
pub selected_index: usize,
pub snippets: Vec<Snippet>,
snippets_loaded: bool,
snippets_enabled: bool,
snippets_cache: Vec<String>,
}
impl SnippetState {
pub fn clear(&mut self) {
self.visible = false;
self.query.clear();
self.selected_index = 0;
self.snippets.clear();
}
pub fn is_enabled(&self) -> bool {
if !self.snippets_loaded {
return false;
}
self.snippets_enabled
}
pub fn needs_load(&self) -> bool {
!self.snippets_loaded
}
pub fn load_snippets(&mut self, workspace_root: &Path, config_dir: &Path) {
if self.snippets_loaded {
return;
}
let mut snippets = Vec::new();
let global_path = config_dir.join("snippets.txt");
if let Ok(content) = std::fs::read_to_string(&global_path) {
Self::parse_snippets_from_content(&content, &mut snippets);
}
let workspace_path = workspace_root.join(".tidev").join("snippets.txt");
if let Ok(content) = std::fs::read_to_string(&workspace_path) {
Self::parse_snippets_from_content(&content, &mut snippets);
}
self.snippets_cache = snippets;
self.snippets_loaded = true;
self.snippets_enabled = !self.snippets_cache.is_empty();
}
fn parse_snippets_from_content(content: &str, snippets: &mut Vec<String>) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
snippets.push(trimmed.to_string());
}
}
pub fn sync(&mut self, workspace_root: &Path, config_dir: &Path, input: &str, cursor: usize) {
self.load_snippets(workspace_root, config_dir);
if !self.snippets_enabled {
self.clear();
return;
}
let mut query = Self::current_word(input, cursor);
let mut best_query = String::new();
let full_word_chars: Vec<char> = query.chars().collect();
for start_idx in 0..full_word_chars.len() {
let possible_query: String = full_word_chars[start_idx..].iter().collect();
if possible_query.len() < 2 {
break;
}
let query_lower = possible_query.to_lowercase();
let query_chars: Vec<char> = query_lower.chars().collect();
let has_match = self.snippets_cache.iter().any(|snippet| {
snippet
.to_lowercase()
.starts_with(&String::from_iter(&query_chars))
});
if has_match {
best_query = possible_query;
break;
}
}
if !best_query.is_empty() {
query = best_query;
}
if query.len() < 2 {
self.clear();
return;
}
if self.visible && self.query == query {
return;
}
self.query = query.to_string();
self.search_snippets();
self.visible = !self.snippets.is_empty();
}
fn current_word(input: &str, cursor: usize) -> String {
if cursor == 0 {
return String::new();
}
let cursor = cursor.min(input.len());
let mut char_count_before_cursor = 0;
for (byte_pos, _c) in input.char_indices() {
if byte_pos >= cursor {
break;
}
char_count_before_cursor += 1;
}
let mut word_char_start = char_count_before_cursor;
let chars: Vec<char> = input.chars().collect();
for i in (0..char_count_before_cursor).rev() {
let c = chars[i];
if c.is_whitespace() || (!c.is_alphanumeric() && c != '_') {
word_char_start = i + 1;
break;
}
word_char_start = i;
}
chars[word_char_start..char_count_before_cursor]
.iter()
.collect()
}
fn search_snippets(&mut self) {
let query_lower = self.query.to_lowercase();
let query_chars: Vec<char> = query_lower.chars().collect();
let mut results: Vec<Snippet> = self
.snippets_cache
.iter()
.filter_map(|snippet| {
let snippet_lower = snippet.to_lowercase();
let (score, matched_indices) = Self::calculate_score(&snippet_lower, &query_chars);
if score > 0 {
Some(Snippet {
text: snippet.clone(),
matched_indices,
score,
})
} else {
None
}
})
.collect();
results.sort_by_key(|b| std::cmp::Reverse(b.score));
results.truncate(MAX_SUGGESTIONS);
self.snippets = results;
self.selected_index = 0;
}
fn calculate_score(snippet: &str, query_chars: &[char]) -> (i64, Vec<usize>) {
if snippet.starts_with(&String::from_iter(query_chars)) {
let matched: Vec<usize> = (0..query_chars.len()).collect();
let score = 1000 + query_chars.len() as i64;
return (score, matched);
}
(0, vec![])
}
pub fn move_selection(&mut self, delta: isize) {
if self.snippets.is_empty() {
return;
}
let len = self.snippets.len() as isize;
let current = self.selected_index as isize;
self.selected_index = (current + delta).rem_euclid(len) as usize;
}
pub fn apply_completion(&self) -> Option<String> {
let selected = self.snippets.get(self.selected_index)?;
Some(selected.text.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_snippets() {
let content = r#"
# This is a comment
hello world
another snippet
"#;
let mut snippets = Vec::new();
SnippetState::parse_snippets_from_content(content, &mut snippets);
assert_eq!(snippets.len(), 2);
assert!(snippets.contains(&"hello world".to_string()));
assert!(snippets.contains(&"another snippet".to_string()));
}
#[test]
fn test_current_word() {
let input = " hello";
let cursor = 3;
let input_before_cursor = &input[..cursor.min(input.len())];
eprintln!(
"input_before_cursor: '{}' (len={})",
input_before_cursor,
input_before_cursor.len()
);
for (i, c) in input_before_cursor.char_indices().rev() {
eprintln!(" i={}, c='{}'", i, c);
}
assert_eq!(SnippetState::current_word("hello world", 0), "");
assert_eq!(SnippetState::current_word("hello world", 1), "h");
assert_eq!(SnippetState::current_word("hello world", 2), "he");
assert_eq!(SnippetState::current_word("hello world", 3), "hel");
assert_eq!(SnippetState::current_word("hello world", 4), "hell");
assert_eq!(SnippetState::current_word("hello world", 5), "hello");
assert_eq!(SnippetState::current_word("hello world", 6), ""); assert_eq!(SnippetState::current_word("hello world", 7), "w");
assert_eq!(SnippetState::current_word("hello world", 8), "wo");
assert_eq!(SnippetState::current_word("hello world", 9), "wor");
assert_eq!(SnippetState::current_word("hello world", 10), "worl");
assert_eq!(SnippetState::current_word("hello world", 11), "world");
}
#[test]
fn test_exact_prefix_match() {
let (score, indices) = SnippetState::calculate_score("hello world", &['h', 'e', 'l']);
assert!(score > 1000);
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn test_no_match() {
let (score, _) = SnippetState::calculate_score("abc", &['x', 'y', 'z']);
assert_eq!(score, 0);
}
#[test]
fn test_non_prefix_no_match() {
let (score, _) = SnippetState::calculate_score("hello world", &['e', 'l', 'o']);
assert_eq!(score, 0);
let (score, _) = SnippetState::calculate_score("hello", &['h', 'a', 'l', 'o']);
assert_eq!(score, 0);
}
}
#[test]
fn test_cjk_support() {
let (score, _) = SnippetState::calculate_score("你好世界", &['你', '好']);
eprintln!("CJK prefix match score: {}", score);
assert!(score > 0, "CJK prefix match should work");
let (score, _) = SnippetState::calculate_score("你好世界", &['好', '世']);
eprintln!("CJK non-prefix match score: {}", score);
assert_eq!(score, 0, "CJK non-prefix should not match");
assert_eq!(SnippetState::current_word("你好世界", 3), "你");
assert_eq!(SnippetState::current_word("你好世界", 6), "你好");
assert_eq!(SnippetState::current_word("你好世界", 9), "你好世");
assert_eq!(SnippetState::current_word("你好世界", 12), "你好世界");
}