Skip to main content

aptu_coder_core/
completion.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder 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    // Verify that search_dir is within root (path traversal check)
38    let canonical_root = match std::fs::canonicalize(root) {
39        Ok(p) => p,
40        Err(_) => return Vec::new(),
41    };
42    let canonical_search = match std::fs::canonicalize(&search_dir) {
43        Ok(p) => p,
44        Err(_) => return Vec::new(),
45    };
46    if !canonical_search.starts_with(&canonical_root) {
47        return Vec::new();
48    }
49
50    let mut results = Vec::new();
51
52    // Walk with depth 1 to get immediate children
53    let mut builder = WalkBuilder::new(&search_dir);
54    builder
55        .hidden(true)
56        .standard_filters(true)
57        .max_depth(Some(1));
58
59    for result in builder.build() {
60        if results.len() >= 100 {
61            break;
62        }
63
64        let Ok(entry) = result else { continue };
65        let path = entry.path();
66        // Skip the root directory itself
67        if path == search_dir {
68            continue;
69        }
70        // Get the filename
71        if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
72            && file_name.starts_with(&name_prefix)
73            && let Ok(rel_path) = path.strip_prefix(root)
74        {
75            // Construct relative path from root
76            let rel_str = rel_path.to_string_lossy().to_string();
77            results.push(rel_str);
78        }
79    }
80
81    results
82}
83
84/// Get symbol completions (function and class names) for a given file path.
85/// Looks up cached [`AnalysisCache`] and extracts matching symbols.
86/// Returns matching function and class names up to 100 results.
87#[instrument(skip(cache), fields(path = %path.display(), prefix = %prefix))]
88pub fn symbol_completions(cache: &AnalysisCache, path: &Path, prefix: &str) -> Vec<String> {
89    if prefix.is_empty() {
90        return Vec::new();
91    }
92
93    // Get file metadata for cache key
94    let cache_key = match std::fs::metadata(path) {
95        Ok(meta) => match meta.modified() {
96            Ok(mtime) => crate::cache::CacheKey {
97                path: path.to_path_buf(),
98                modified: mtime,
99                mode: crate::types::AnalysisMode::FileDetails,
100            },
101            Err(_) => return Vec::new(),
102        },
103        Err(_) => return Vec::new(),
104    };
105
106    // Look up in cache
107    let Some(cached) = cache.get(&cache_key) else {
108        return Vec::new();
109    };
110
111    let mut results = Vec::new();
112
113    // Extract function names matching prefix
114    for func in &cached.semantic.functions {
115        if results.len() >= 100 {
116            break;
117        }
118        if func.name.starts_with(prefix) {
119            results.push(func.name.clone());
120        }
121    }
122
123    // Extract class names matching prefix
124    for class in &cached.semantic.classes {
125        if results.len() >= 100 {
126            break;
127        }
128        if class.name.starts_with(prefix) {
129            results.push(class.name.clone());
130        }
131    }
132
133    results
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::fs;
140    use tempfile::TempDir;
141
142    #[test]
143    fn test_path_completions_slash_prefix() {
144        // Arrange: create temp dir with src/main.rs
145        let temp = TempDir::new().unwrap();
146        let root = temp.path();
147        fs::create_dir(root.join("src")).unwrap();
148        fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
149        // Act: search with slash-separated prefix
150        let results = path_completions(root, "src/ma");
151        // Assert: result contains the relative path
152        assert!(
153            results.iter().any(|r| r.contains("main.rs")),
154            "expected 'main.rs' in completions, got {:?}",
155            results
156        );
157    }
158
159    #[test]
160    // Distinct from test_path_completions_slash_prefix: exercises the early-return branch
161    // in path_completions when prefix is "" (no directory component to search).
162    fn test_path_completions_empty_prefix() {
163        // Edge case: empty prefix returns empty vec
164        let temp = TempDir::new().unwrap();
165        let results = path_completions(temp.path(), "");
166        assert!(
167            results.is_empty(),
168            "expected empty results for empty prefix"
169        );
170    }
171
172    #[test]
173    fn test_path_completions_rejects_parent_traversal() {
174        // Edge case: prefix with ../ should be rejected by canonicalize guard
175        let temp = TempDir::new().unwrap();
176        let root = temp.path();
177
178        // Create a file in the temp dir
179        fs::write(root.join("file.rs"), "fn main() {}").unwrap();
180
181        // Try to traverse outside root with ../
182        let results = path_completions(root, "../");
183        assert!(
184            results.is_empty(),
185            "expected empty results for parent traversal attempt (../)"
186        );
187
188        // Try with ../../
189        let results = path_completions(root, "../../");
190        assert!(
191            results.is_empty(),
192            "expected empty results for parent traversal attempt (../../)"
193        );
194    }
195}