use std::collections::HashMap;
use std::time::Instant;
#[derive(Debug)]
pub struct FileCompletionCache {
all_files: Vec<String>,
by_basename: HashMap<String, Vec<usize>>,
by_directory: HashMap<String, Vec<usize>>,
quick_hash: u64,
cached_count: usize,
last_used: Instant,
}
impl Default for FileCompletionCache {
fn default() -> Self {
Self {
all_files: Vec::new(),
by_basename: HashMap::new(),
by_directory: HashMap::new(),
quick_hash: 0,
cached_count: 0,
last_used: Instant::now(),
}
}
}
impl FileCompletionCache {
pub fn new(source_files: &[String]) -> Self {
let mut cache = Self::default();
cache.rebuild_cache(source_files);
cache
}
pub fn get_file_completion(&mut self, input: &str) -> Option<String> {
self.last_used = Instant::now();
let (command_prefix, file_part) = extract_file_context(input)?;
tracing::debug!(
"File completion for command '{}', file part '{}'",
command_prefix,
file_part
);
let candidates = self.find_completion_candidates(file_part);
if candidates.is_empty() {
return None;
}
if candidates.len() == 1 {
let full_path = &self.all_files[candidates[0]];
Some(self.calculate_completion(file_part, full_path))
} else {
self.find_common_completion_prefix(file_part, &candidates)
}
}
pub fn sync_from_source_panel(&mut self, source_files: &[String]) -> bool {
let new_count = source_files.len();
let new_hash = Self::calculate_quick_hash(source_files);
if new_count == self.cached_count && new_hash == self.quick_hash {
return false;
}
tracing::debug!(
"File completion cache updating: {} -> {} files",
self.cached_count,
new_count
);
self.rebuild_cache(source_files);
true
}
pub fn should_cleanup(&self) -> bool {
self.last_used.elapsed().as_secs() > 300 }
pub fn get_all_files(&self) -> &[String] {
&self.all_files
}
pub fn set_all_files(&mut self, files: Vec<String>) {
self.rebuild_cache(&files);
}
pub fn len(&self) -> usize {
self.all_files.len()
}
pub fn is_empty(&self) -> bool {
self.all_files.is_empty()
}
fn rebuild_cache(&mut self, source_files: &[String]) {
self.all_files.clear();
self.by_basename.clear();
self.by_directory.clear();
self.all_files.extend_from_slice(source_files);
self.cached_count = source_files.len();
self.quick_hash = Self::calculate_quick_hash(source_files);
for (idx, file_path) in self.all_files.iter().enumerate() {
if let Some(basename) = Self::extract_basename(file_path) {
self.by_basename
.entry(basename.to_string())
.or_default()
.push(idx);
}
if let Some(dir) = Self::extract_directory(file_path) {
self.by_directory
.entry(dir.to_string())
.or_default()
.push(idx);
}
}
tracing::debug!(
"File completion cache rebuilt: {} files, {} basenames, {} directories",
self.cached_count,
self.by_basename.len(),
self.by_directory.len()
);
}
fn find_completion_candidates(&self, file_input: &str) -> Vec<usize> {
if file_input.is_empty() {
return Vec::new();
}
let mut candidates = Vec::new();
let file_input_lower = file_input.to_lowercase();
for (idx, full_path) in self.all_files.iter().enumerate() {
if let Some(relative) = Self::make_relative_path(full_path) {
if relative.to_lowercase().starts_with(&file_input_lower) {
candidates.push(idx);
}
}
}
if candidates.is_empty() {
for (idx, full_path) in self.all_files.iter().enumerate() {
if let Some(basename) = Self::extract_basename(full_path) {
if basename.to_lowercase().starts_with(&file_input_lower) {
candidates.push(idx);
}
}
}
}
if candidates.is_empty() {
for (idx, full_path) in self.all_files.iter().enumerate() {
if full_path.to_lowercase().contains(&file_input_lower) {
candidates.push(idx);
}
}
}
candidates.truncate(100);
candidates
}
fn calculate_completion(&self, user_input: &str, full_path: &str) -> String {
tracing::debug!(
"calculate_completion: user_input='{}', full_path='{}'",
user_input,
full_path
);
if let Some(relative) = Self::make_relative_path(full_path) {
tracing::debug!("relative path: '{}'", relative);
if relative
.to_lowercase()
.starts_with(&user_input.to_lowercase())
{
let completion = relative[user_input.len()..].to_string();
tracing::debug!("relative match: completion='{}'", completion);
return completion;
}
}
if let Some(basename) = Self::extract_basename(full_path) {
tracing::debug!("basename: '{}'", basename);
if basename
.to_lowercase()
.starts_with(&user_input.to_lowercase())
{
let completion = basename[user_input.len()..].to_string();
tracing::debug!("basename match: completion='{}'", completion);
return completion;
}
}
tracing::debug!("no match found, returning empty");
String::new()
}
fn find_common_completion_prefix(
&self,
user_input: &str,
candidates: &[usize],
) -> Option<String> {
if candidates.len() < 2 {
return None;
}
let completions: Vec<String> = candidates
.iter()
.map(|&idx| {
let full_path = &self.all_files[idx];
self.calculate_completion(user_input, full_path)
})
.collect();
if let Some(first) = completions.first() {
let mut common_len = first.len();
for completion in &completions[1..] {
let matching_chars = first
.chars()
.zip(completion.chars())
.take_while(|(a, b)| a.eq_ignore_ascii_case(b))
.count();
common_len = common_len.min(matching_chars);
}
if common_len > 0 {
let common_prefix = &first[..common_len];
if common_prefix.trim().len() > 1 {
return Some(common_prefix.to_string());
}
}
}
None
}
fn calculate_quick_hash(files: &[String]) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
files.len().hash(&mut hasher);
files.iter().take(10).for_each(|f| f.hash(&mut hasher));
hasher.finish()
}
fn extract_basename(path: &str) -> Option<&str> {
path.rsplit('/').next()
}
fn extract_directory(path: &str) -> Option<&str> {
if let Some(last_slash) = path.rfind('/') {
let dir = &path[..last_slash];
if let Some(second_last_slash) = dir.rfind('/') {
Some(&dir[second_last_slash + 1..])
} else {
Some(dir)
}
} else {
None
}
}
fn make_relative_path(full_path: &str) -> Option<&str> {
let common_dirs = ["src/", "lib/", "include/", "tests/", "test/"];
for dir in &common_dirs {
if let Some(pos) = full_path.find(dir) {
return Some(&full_path[pos..]);
}
}
Self::extract_basename(full_path)
}
}
pub fn extract_file_context(input: &str) -> Option<(&str, &str)> {
let input = input.trim();
if let Some(file_part) = input.strip_prefix("info line ") {
return Some(("info line ", extract_file_part_from_line_spec(file_part)));
}
if let Some(file_part) = input.strip_prefix("i l ") {
return Some(("i l ", extract_file_part_from_line_spec(file_part)));
}
if let Some(file_part) = input.strip_prefix("trace ") {
if contains_path_chars(file_part) {
return Some(("trace ", extract_file_part_from_line_spec(file_part)));
}
}
if let Some(file_part) = input.strip_prefix("source ") {
return Some(("source ", file_part));
}
if let Some(file_part) = input.strip_prefix("save traces ") {
let file_part = file_part
.strip_prefix("enabled ")
.or_else(|| file_part.strip_prefix("disabled "))
.unwrap_or(file_part);
if !file_part.is_empty() {
return Some(("save traces ", file_part));
}
}
if let Some(file_part) = input.strip_prefix("s ") {
if !file_part.starts_with("t ") {
return Some(("s ", file_part));
}
}
if let Some(file_part) = input.strip_prefix("s t ") {
let file_part = file_part
.strip_prefix("enabled ")
.or_else(|| file_part.strip_prefix("disabled "))
.unwrap_or(file_part);
if !file_part.is_empty() {
return Some(("s t ", file_part));
}
}
None
}
fn extract_file_part_from_line_spec(spec: &str) -> &str {
spec.split(':').next().unwrap_or(spec)
}
fn contains_path_chars(input: &str) -> bool {
input.contains('/') || input.contains('.')
}
pub fn needs_file_completion(input: &str) -> bool {
extract_file_context(input).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_file_context() {
assert_eq!(
extract_file_context("info line main.c:42"),
Some(("info line ", "main.c"))
);
assert_eq!(
extract_file_context("i l src/utils.h:10"),
Some(("i l ", "src/utils.h"))
);
assert_eq!(
extract_file_context("trace main.c:100"),
Some(("trace ", "main.c"))
);
assert_eq!(
extract_file_context("trace function_name"),
None );
assert_eq!(extract_file_context("help"), None);
}
#[test]
fn test_file_completion_basic() {
let files = vec![
"/full/path/to/src/main.c".to_string(),
"/full/path/to/src/utils.c".to_string(),
"/full/path/to/include/header.h".to_string(),
];
let mut cache = FileCompletionCache::new(&files);
assert_eq!(
cache.get_file_completion("info line main."),
Some("c".to_string())
);
assert_eq!(
cache.get_file_completion("i l src/mai"),
Some("n.c".to_string())
);
}
#[test]
fn test_file_completion_multiple_matches() {
let files = vec![
"/path/src/main.c".to_string(),
"/path/src/main.h".to_string(),
"/path/src/manager.c".to_string(),
];
let mut cache = FileCompletionCache::new(&files);
assert_eq!(
cache.get_file_completion("info line mai"),
Some("n.".to_string()) );
}
#[test]
fn test_needs_file_completion() {
assert!(needs_file_completion("info line main.c"));
assert!(needs_file_completion("i l src/utils.h:42"));
assert!(needs_file_completion("trace file.c:100"));
assert!(!needs_file_completion("trace function_name"));
assert!(!needs_file_completion("help"));
assert!(!needs_file_completion("enable 1"));
}
}