ghostscope_ui/components/command_panel/
file_completion.rs1use std::collections::HashMap;
2use std::time::Instant;
3
4#[derive(Debug)]
6pub struct FileCompletionCache {
7 all_files: Vec<String>,
9
10 by_basename: HashMap<String, Vec<usize>>,
12
13 by_directory: HashMap<String, Vec<usize>>,
15
16 quick_hash: u64,
18
19 cached_count: usize,
21
22 last_used: Instant,
24}
25
26impl Default for FileCompletionCache {
27 fn default() -> Self {
28 Self {
29 all_files: Vec::new(),
30 by_basename: HashMap::new(),
31 by_directory: HashMap::new(),
32 quick_hash: 0,
33 cached_count: 0,
34 last_used: Instant::now(),
35 }
36 }
37}
38
39impl FileCompletionCache {
40 pub fn new(source_files: &[String]) -> Self {
42 let mut cache = Self::default();
43 cache.rebuild_cache(source_files);
44 cache
45 }
46
47 pub fn get_file_completion(&mut self, input: &str) -> Option<String> {
49 self.last_used = Instant::now();
50
51 let (command_prefix, file_part) = extract_file_context(input)?;
53
54 tracing::debug!(
55 "File completion for command '{}', file part '{}'",
56 command_prefix,
57 file_part
58 );
59
60 let candidates = self.find_completion_candidates(file_part);
62
63 if candidates.is_empty() {
64 return None;
65 }
66
67 if candidates.len() == 1 {
68 let full_path = &self.all_files[candidates[0]];
70 Some(self.calculate_completion(file_part, full_path))
71 } else {
72 self.find_common_completion_prefix(file_part, &candidates)
74 }
75 }
76
77 pub fn sync_from_source_panel(&mut self, source_files: &[String]) -> bool {
79 let new_count = source_files.len();
80 let new_hash = Self::calculate_quick_hash(source_files);
81
82 if new_count == self.cached_count && new_hash == self.quick_hash {
84 return false;
85 }
86
87 tracing::debug!(
88 "File completion cache updating: {} -> {} files",
89 self.cached_count,
90 new_count
91 );
92 self.rebuild_cache(source_files);
93 true
94 }
95
96 pub fn should_cleanup(&self) -> bool {
98 self.last_used.elapsed().as_secs() > 300 }
100
101 pub fn get_all_files(&self) -> &[String] {
103 &self.all_files
104 }
105
106 pub fn set_all_files(&mut self, files: Vec<String>) {
108 self.rebuild_cache(&files);
109 }
110
111 pub fn len(&self) -> usize {
113 self.all_files.len()
114 }
115
116 pub fn is_empty(&self) -> bool {
118 self.all_files.is_empty()
119 }
120
121 fn rebuild_cache(&mut self, source_files: &[String]) {
123 self.all_files.clear();
124 self.by_basename.clear();
125 self.by_directory.clear();
126
127 self.all_files.extend_from_slice(source_files);
128 self.cached_count = source_files.len();
129 self.quick_hash = Self::calculate_quick_hash(source_files);
130
131 for (idx, file_path) in self.all_files.iter().enumerate() {
133 if let Some(basename) = Self::extract_basename(file_path) {
134 self.by_basename
135 .entry(basename.to_string())
136 .or_default()
137 .push(idx);
138 }
139
140 if let Some(dir) = Self::extract_directory(file_path) {
142 self.by_directory
143 .entry(dir.to_string())
144 .or_default()
145 .push(idx);
146 }
147 }
148
149 tracing::debug!(
150 "File completion cache rebuilt: {} files, {} basenames, {} directories",
151 self.cached_count,
152 self.by_basename.len(),
153 self.by_directory.len()
154 );
155 }
156
157 fn find_completion_candidates(&self, file_input: &str) -> Vec<usize> {
159 if file_input.is_empty() {
160 return Vec::new();
161 }
162
163 let mut candidates = Vec::new();
164 let file_input_lower = file_input.to_lowercase();
165
166 for (idx, full_path) in self.all_files.iter().enumerate() {
168 if let Some(relative) = Self::make_relative_path(full_path) {
169 if relative.to_lowercase().starts_with(&file_input_lower) {
170 candidates.push(idx);
171 }
172 }
173 }
174
175 if candidates.is_empty() {
177 for (idx, full_path) in self.all_files.iter().enumerate() {
178 if let Some(basename) = Self::extract_basename(full_path) {
179 if basename.to_lowercase().starts_with(&file_input_lower) {
180 candidates.push(idx);
181 }
182 }
183 }
184 }
185
186 if candidates.is_empty() {
188 for (idx, full_path) in self.all_files.iter().enumerate() {
189 if full_path.to_lowercase().contains(&file_input_lower) {
190 candidates.push(idx);
191 }
192 }
193 }
194
195 candidates.truncate(100);
197 candidates
198 }
199
200 fn calculate_completion(&self, user_input: &str, full_path: &str) -> String {
202 tracing::debug!(
203 "calculate_completion: user_input='{}', full_path='{}'",
204 user_input,
205 full_path
206 );
207
208 if let Some(relative) = Self::make_relative_path(full_path) {
210 tracing::debug!("relative path: '{}'", relative);
211 if relative
212 .to_lowercase()
213 .starts_with(&user_input.to_lowercase())
214 {
215 let completion = relative[user_input.len()..].to_string();
216 tracing::debug!("relative match: completion='{}'", completion);
217 return completion;
218 }
219 }
220
221 if let Some(basename) = Self::extract_basename(full_path) {
223 tracing::debug!("basename: '{}'", basename);
224 if basename
225 .to_lowercase()
226 .starts_with(&user_input.to_lowercase())
227 {
228 let completion = basename[user_input.len()..].to_string();
229 tracing::debug!("basename match: completion='{}'", completion);
230 return completion;
231 }
232 }
233
234 tracing::debug!("no match found, returning empty");
235 String::new()
236 }
237
238 fn find_common_completion_prefix(
240 &self,
241 user_input: &str,
242 candidates: &[usize],
243 ) -> Option<String> {
244 if candidates.len() < 2 {
245 return None;
246 }
247
248 let completions: Vec<String> = candidates
250 .iter()
251 .map(|&idx| {
252 let full_path = &self.all_files[idx];
253 self.calculate_completion(user_input, full_path)
254 })
255 .collect();
256
257 if let Some(first) = completions.first() {
259 let mut common_len = first.len();
260
261 for completion in &completions[1..] {
262 let matching_chars = first
263 .chars()
264 .zip(completion.chars())
265 .take_while(|(a, b)| a.eq_ignore_ascii_case(b))
266 .count();
267 common_len = common_len.min(matching_chars);
268 }
269
270 if common_len > 0 {
271 let common_prefix = &first[..common_len];
272 if common_prefix.trim().len() > 1 {
274 return Some(common_prefix.to_string());
275 }
276 }
277 }
278
279 None
280 }
281
282 fn calculate_quick_hash(files: &[String]) -> u64 {
284 use std::collections::hash_map::DefaultHasher;
285 use std::hash::{Hash, Hasher};
286
287 let mut hasher = DefaultHasher::new();
288 files.len().hash(&mut hasher);
289
290 files.iter().take(10).for_each(|f| f.hash(&mut hasher));
292
293 hasher.finish()
294 }
295
296 fn extract_basename(path: &str) -> Option<&str> {
298 path.rsplit('/').next()
299 }
300
301 fn extract_directory(path: &str) -> Option<&str> {
303 if let Some(last_slash) = path.rfind('/') {
304 let dir = &path[..last_slash];
305 if let Some(second_last_slash) = dir.rfind('/') {
306 Some(&dir[second_last_slash + 1..])
307 } else {
308 Some(dir)
309 }
310 } else {
311 None
312 }
313 }
314
315 fn make_relative_path(full_path: &str) -> Option<&str> {
317 let common_dirs = ["src/", "lib/", "include/", "tests/", "test/"];
320
321 for dir in &common_dirs {
322 if let Some(pos) = full_path.find(dir) {
323 return Some(&full_path[pos..]);
324 }
325 }
326
327 Self::extract_basename(full_path)
329 }
330}
331
332pub fn extract_file_context(input: &str) -> Option<(&str, &str)> {
334 let input = input.trim();
335
336 if let Some(file_part) = input.strip_prefix("info line ") {
337 return Some(("info line ", extract_file_part_from_line_spec(file_part)));
338 }
339
340 if let Some(file_part) = input.strip_prefix("i l ") {
341 return Some(("i l ", extract_file_part_from_line_spec(file_part)));
342 }
343
344 if let Some(file_part) = input.strip_prefix("trace ") {
345 if contains_path_chars(file_part) {
348 return Some(("trace ", extract_file_part_from_line_spec(file_part)));
349 }
350 }
351
352 if let Some(file_part) = input.strip_prefix("source ") {
354 return Some(("source ", file_part));
355 }
356
357 if let Some(file_part) = input.strip_prefix("save traces ") {
359 let file_part = file_part
361 .strip_prefix("enabled ")
362 .or_else(|| file_part.strip_prefix("disabled "))
363 .unwrap_or(file_part);
364 if !file_part.is_empty() {
365 return Some(("save traces ", file_part));
366 }
367 }
368
369 if let Some(file_part) = input.strip_prefix("s ") {
371 if !file_part.starts_with("t ") {
373 return Some(("s ", file_part));
374 }
375 }
376
377 if let Some(file_part) = input.strip_prefix("s t ") {
378 let file_part = file_part
380 .strip_prefix("enabled ")
381 .or_else(|| file_part.strip_prefix("disabled "))
382 .unwrap_or(file_part);
383 if !file_part.is_empty() {
384 return Some(("s t ", file_part));
385 }
386 }
387
388 None
389}
390
391fn extract_file_part_from_line_spec(spec: &str) -> &str {
393 spec.split(':').next().unwrap_or(spec)
395}
396
397fn contains_path_chars(input: &str) -> bool {
399 input.contains('/') || input.contains('.')
400}
401
402pub fn needs_file_completion(input: &str) -> bool {
404 extract_file_context(input).is_some()
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_extract_file_context() {
413 assert_eq!(
414 extract_file_context("info line main.c:42"),
415 Some(("info line ", "main.c"))
416 );
417
418 assert_eq!(
419 extract_file_context("i l src/utils.h:10"),
420 Some(("i l ", "src/utils.h"))
421 );
422
423 assert_eq!(
424 extract_file_context("trace main.c:100"),
425 Some(("trace ", "main.c"))
426 );
427
428 assert_eq!(
429 extract_file_context("trace function_name"),
430 None );
432
433 assert_eq!(extract_file_context("help"), None);
434 }
435
436 #[test]
437 fn test_file_completion_basic() {
438 let files = vec![
439 "/full/path/to/src/main.c".to_string(),
440 "/full/path/to/src/utils.c".to_string(),
441 "/full/path/to/include/header.h".to_string(),
442 ];
443
444 let mut cache = FileCompletionCache::new(&files);
445
446 assert_eq!(
448 cache.get_file_completion("info line main."),
449 Some("c".to_string())
450 );
451
452 assert_eq!(
454 cache.get_file_completion("i l src/mai"),
455 Some("n.c".to_string())
456 );
457 }
458
459 #[test]
460 fn test_file_completion_multiple_matches() {
461 let files = vec![
462 "/path/src/main.c".to_string(),
463 "/path/src/main.h".to_string(),
464 "/path/src/manager.c".to_string(),
465 ];
466
467 let mut cache = FileCompletionCache::new(&files);
468
469 assert_eq!(
471 cache.get_file_completion("info line mai"),
472 Some("n.".to_string()) );
474 }
475
476 #[test]
477 fn test_needs_file_completion() {
478 assert!(needs_file_completion("info line main.c"));
479 assert!(needs_file_completion("i l src/utils.h:42"));
480 assert!(needs_file_completion("trace file.c:100"));
481 assert!(!needs_file_completion("trace function_name"));
482 assert!(!needs_file_completion("help"));
483 assert!(!needs_file_completion("enable 1"));
484 }
485}