Skip to main content

code_analyze_core/
completion.rs

1// SPDX-FileCopyrightText: 2026 code-analyze-mcp contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Path completion support for file and directory paths.
4//!
5//! Provides completion suggestions for partial paths within a directory tree,
6//! respecting .gitignore and .ignore files.
7
8use crate::cache::AnalysisCache;
9use ignore::WalkBuilder;
10use std::path::Path;
11use tracing::instrument;
12
13/// Get path completions for a given prefix within a root directory.
14/// Uses ignore crate with standard filters to respect `.gitignore`.
15/// Returns matching file and directory paths up to 100 results.
16#[instrument(skip_all, fields(prefix = %prefix))]
17pub fn path_completions(root: &Path, prefix: &str) -> Vec<String> {
18    if prefix.is_empty() {
19        return Vec::new();
20    }
21
22    // Determine the search directory and filename prefix
23    let (search_dir, name_prefix) = if let Some(last_slash) = prefix.rfind('/') {
24        let dir_part = &prefix[..=last_slash];
25        let name_part = &prefix[last_slash + 1..];
26        let full_path = root.join(dir_part);
27        (full_path, name_part.to_string())
28    } else {
29        (root.to_path_buf(), prefix.to_string())
30    };
31
32    // If search directory doesn't exist, return empty
33    if !search_dir.exists() {
34        return Vec::new();
35    }
36
37    let mut results = Vec::new();
38
39    // Walk with depth 1 to get immediate children
40    let mut builder = WalkBuilder::new(&search_dir);
41    builder
42        .hidden(true)
43        .standard_filters(true)
44        .max_depth(Some(1));
45
46    for result in builder.build() {
47        if results.len() >= 100 {
48            break;
49        }
50
51        let Ok(entry) = result else { continue };
52        let path = entry.path();
53        // Skip the root directory itself
54        if path == search_dir {
55            continue;
56        }
57        // Get the filename
58        if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
59            && file_name.starts_with(&name_prefix)
60        {
61            // Construct relative path from root
62            if let Ok(rel_path) = path.strip_prefix(root) {
63                let rel_str = rel_path.to_string_lossy().to_string();
64                results.push(rel_str);
65            }
66        }
67    }
68
69    results
70}
71
72/// Get symbol completions (function and class names) for a given file path.
73/// Looks up cached [`AnalysisCache`] and extracts matching symbols.
74/// Returns matching function and class names up to 100 results.
75#[instrument(skip(cache), fields(path = %path.display(), prefix = %prefix))]
76pub fn symbol_completions(cache: &AnalysisCache, path: &Path, prefix: &str) -> Vec<String> {
77    if prefix.is_empty() {
78        return Vec::new();
79    }
80
81    // Get file metadata for cache key
82    let cache_key = match std::fs::metadata(path) {
83        Ok(meta) => match meta.modified() {
84            Ok(mtime) => crate::cache::CacheKey {
85                path: path.to_path_buf(),
86                modified: mtime,
87                mode: crate::types::AnalysisMode::FileDetails,
88            },
89            Err(_) => return Vec::new(),
90        },
91        Err(_) => return Vec::new(),
92    };
93
94    // Look up in cache
95    let Some(cached) = cache.get(&cache_key) else {
96        return Vec::new();
97    };
98
99    let mut results = Vec::new();
100
101    // Extract function names matching prefix
102    for func in &cached.semantic.functions {
103        if results.len() >= 100 {
104            break;
105        }
106        if func.name.starts_with(prefix) {
107            results.push(func.name.clone());
108        }
109    }
110
111    // Extract class names matching prefix
112    for class in &cached.semantic.classes {
113        if results.len() >= 100 {
114            break;
115        }
116        if class.name.starts_with(prefix) {
117            results.push(class.name.clone());
118        }
119    }
120
121    results
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::fs;
128    use tempfile::TempDir;
129
130    #[test]
131    fn test_path_completions_slash_prefix() {
132        // Arrange: create temp dir with src/main.rs
133        let temp = TempDir::new().unwrap();
134        let root = temp.path();
135        fs::create_dir(root.join("src")).unwrap();
136        fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
137        // Act: search with slash-separated prefix
138        let results = path_completions(root, "src/ma");
139        // Assert: result contains the relative path
140        assert!(
141            results.iter().any(|r| r.contains("main.rs")),
142            "expected 'main.rs' in completions, got {:?}",
143            results
144        );
145    }
146
147    #[test]
148    // Distinct from test_path_completions_slash_prefix: exercises the early-return branch
149    // in path_completions when prefix is "" (no directory component to search).
150    fn test_path_completions_empty_prefix() {
151        // Edge case: empty prefix returns empty vec
152        let temp = TempDir::new().unwrap();
153        let results = path_completions(temp.path(), "");
154        assert!(
155            results.is_empty(),
156            "expected empty results for empty prefix"
157        );
158    }
159}