ghostscope_ui/components/command_panel/
file_completion.rs

1use std::collections::HashMap;
2use std::time::Instant;
3
4/// File completion cache for command auto-completion
5#[derive(Debug)]
6pub struct FileCompletionCache {
7    /// All source files with full paths
8    all_files: Vec<String>,
9
10    /// Index by basename for fast lookup: "main.c" -> [file_index, ...]
11    by_basename: HashMap<String, Vec<usize>>,
12
13    /// Index by directory for path-based completion: "src" -> [file_index, ...]
14    by_directory: HashMap<String, Vec<usize>>,
15
16    /// Quick hash for change detection
17    quick_hash: u64,
18
19    /// Number of files cached
20    cached_count: usize,
21
22    /// Last time this cache was used
23    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    /// Create new file completion cache from source files
41    pub fn new(source_files: &[String]) -> Self {
42        let mut cache = Self::default();
43        cache.rebuild_cache(source_files);
44        cache
45    }
46
47    /// Get file completion for the given input
48    pub fn get_file_completion(&mut self, input: &str) -> Option<String> {
49        self.last_used = Instant::now();
50
51        // Extract command and file part
52        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        // Get completion candidates
61        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            // Single match - return the completion
69            let full_path = &self.all_files[candidates[0]];
70            Some(self.calculate_completion(file_part, full_path))
71        } else {
72            // Multiple matches - find common prefix
73            self.find_common_completion_prefix(file_part, &candidates)
74        }
75    }
76
77    /// Sync cache from source panel files, returns true if updated
78    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        // Quick check: no change if count and hash match
83        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    /// Check if cache has been unused for too long
97    pub fn should_cleanup(&self) -> bool {
98        self.last_used.elapsed().as_secs() > 300 // 5 minutes
99    }
100
101    /// Get all cached file paths (for source panel reuse)
102    pub fn get_all_files(&self) -> &[String] {
103        &self.all_files
104    }
105
106    /// Set all files and rebuild cache
107    pub fn set_all_files(&mut self, files: Vec<String>) {
108        self.rebuild_cache(&files);
109    }
110
111    /// Get number of cached files
112    pub fn len(&self) -> usize {
113        self.all_files.len()
114    }
115
116    /// Check if cache is empty
117    pub fn is_empty(&self) -> bool {
118        self.all_files.is_empty()
119    }
120
121    /// Rebuild the entire cache
122    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        // Build basename index
132        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            // Build directory index
141            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    /// Find completion candidates based on input
158    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        // Strategy 1: Exact prefix match on relative paths
167        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        // Strategy 2: If no prefix matches, try basename matching
176        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        // Strategy 3: If still no matches, try contains matching
187        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        // Limit candidates to avoid performance issues
196        candidates.truncate(100);
197        candidates
198    }
199
200    /// Calculate completion string for a single match
201    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        // Extract the part that user hasn't typed yet
209        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        // Fallback: return basename if prefix doesn't match
222        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    /// Find common prefix among multiple candidates
239    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        // Get completion strings for all candidates
249        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        // Find common prefix
258        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                // Don't complete with just whitespace or single character
273                if common_prefix.trim().len() > 1 {
274                    return Some(common_prefix.to_string());
275                }
276            }
277        }
278
279        None
280    }
281
282    /// Calculate quick hash for change detection
283    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        // Hash first 10 files for quick comparison
291        files.iter().take(10).for_each(|f| f.hash(&mut hasher));
292
293        hasher.finish()
294    }
295
296    /// Extract basename from full path
297    fn extract_basename(path: &str) -> Option<&str> {
298        path.rsplit('/').next()
299    }
300
301    /// Extract directory from full path
302    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    /// Convert full path to relative path for completion
316    fn make_relative_path(full_path: &str) -> Option<&str> {
317        // Simple heuristic: find common path prefixes to strip
318        // For now, just strip everything before src/, lib/, include/, or similar
319        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        // Fallback: use basename
328        Self::extract_basename(full_path)
329    }
330}
331
332/// Extract command prefix and file part from input
333pub 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        // For trace command, only provide file completion if it contains path chars
346        // This avoids triggering completion for function names
347        if contains_path_chars(file_part) {
348            return Some(("trace ", extract_file_part_from_line_spec(file_part)));
349        }
350    }
351
352    // Support file completion for source command
353    if let Some(file_part) = input.strip_prefix("source ") {
354        return Some(("source ", file_part));
355    }
356
357    // Support file completion for save traces command
358    if let Some(file_part) = input.strip_prefix("save traces ") {
359        // Skip filter keywords
360        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    // Support abbreviations
370    if let Some(file_part) = input.strip_prefix("s ") {
371        // Not "s t" which is save traces abbreviation
372        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        // Skip filter keywords for save traces abbreviation
379        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
391/// Extract file part from "file:line" specification
392fn extract_file_part_from_line_spec(spec: &str) -> &str {
393    // Split on ':' and take the file part
394    spec.split(':').next().unwrap_or(spec)
395}
396
397/// Check if input contains path-like characters
398fn contains_path_chars(input: &str) -> bool {
399    input.contains('/') || input.contains('.')
400}
401
402/// Check if input needs file completion
403pub 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 // No path chars
431        );
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        // Test exact match
447        assert_eq!(
448            cache.get_file_completion("info line main."),
449            Some("c".to_string())
450        );
451
452        // Test prefix match
453        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        // Should return common prefix
470        assert_eq!(
471            cache.get_file_completion("info line mai"),
472            Some("n.".to_string()) // Common prefix of "main.c", "main.h"
473        );
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}