lattice_completion/
path.rs1use std::path::PathBuf;
21
22use crate::candidate::{CandidateData, CandidateKind, RawCandidate};
23use crate::insert::{InsertContext, PATH_SOURCE_ID, SourceId};
24use crate::source::SyncCompletionSource;
25
26const IGNORE_NAMES: &[&str] = &[".git", "node_modules", "target", "dist"];
30
31const MAX_ENTRIES: usize = 200;
35
36#[derive(Debug, Clone, Default)]
40pub struct PathCompletionSource;
41
42impl SyncCompletionSource for PathCompletionSource {
43 fn produce(&self, ctx: &InsertContext<'_>) -> Vec<RawCandidate> {
44 if !ctx.path_context {
48 return Vec::new();
49 }
50 let Some(buffer_dir) = ctx.buffer_dir else {
51 return Vec::new();
52 };
53
54 let line_text = ctx.buffer.line(ctx.cursor.line).unwrap_or_default();
60 let line_bytes = line_text.as_bytes();
61 let cursor_in_line = (ctx.cursor.byte as usize).min(line_bytes.len());
62 let mut path_start = cursor_in_line;
63 while path_start > 0 {
64 let b = line_bytes[path_start - 1];
65 if b == b'/' || is_path_byte(b) {
66 path_start -= 1;
67 } else {
68 break;
69 }
70 }
71 let partial: &str = &line_text[path_start..cursor_in_line];
72 let dir_part = match partial.rfind('/') {
73 Some(i) => &partial[..=i], None => "",
75 };
76 let base_dir: PathBuf = if dir_part.starts_with('/') {
77 PathBuf::from(dir_part)
78 } else if dir_part.is_empty() {
79 buffer_dir.to_path_buf()
80 } else {
81 buffer_dir.join(dir_part)
82 };
83
84 let Ok(read) = std::fs::read_dir(&base_dir) else {
85 return Vec::new();
86 };
87 let mut entries: Vec<(String, bool)> = read
88 .flatten()
89 .filter_map(|entry| {
90 entry.file_name().to_str().map(|name| {
91 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
92 (name.to_string(), is_dir)
93 })
94 })
95 .collect();
96 entries.sort_by(|a, b| a.0.cmp(&b.0));
97
98 let source_id = SourceId::new(PATH_SOURCE_ID);
99 let mut out: Vec<RawCandidate> = Vec::with_capacity(entries.len().min(MAX_ENTRIES));
100 for (name, is_dir) in entries {
101 if out.len() >= MAX_ENTRIES {
102 break;
103 }
104 if IGNORE_NAMES.iter().any(|n| *n == name) {
105 continue;
106 }
107 if name.starts_with('.') {
112 continue;
113 }
114 let display = if is_dir {
115 format!("{name}/")
116 } else {
117 name.clone()
118 };
119 let text = if is_dir {
124 format!("{name}/")
125 } else {
126 name.clone()
127 };
128 let mut cand = RawCandidate::plain(
129 text,
130 if is_dir {
131 CandidateKind::Directory
132 } else {
133 CandidateKind::File
134 },
135 )
136 .with_source(source_id.clone());
137 cand.display = display;
138 cand.data = CandidateData::File {
139 path: base_dir.join(&name),
140 is_dir,
141 size: None,
142 };
143 out.push(cand);
144 }
145 out
146 }
147}
148
149fn is_path_byte(b: u8) -> bool {
154 matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'.' | b'-')
155}
156
157#[cfg(test)]
158mod tests {
159 #![allow(clippy::unwrap_used)]
160
161 use super::*;
162 use crate::insert::CompletionTrigger;
163 use lattice_core::Buffer;
164 use lattice_protocol::Position;
165 use std::fs;
166
167 fn make_buffer(text: &str) -> Buffer {
168 let mut b = Buffer::empty();
169 let _ = b.apply_edit(&lattice_protocol::edit::Edit::insert(
170 Position::ZERO,
171 text.to_string(),
172 ));
173 b
174 }
175
176 #[test]
177 fn returns_empty_outside_path_context() {
178 let buffer = make_buffer("foo");
179 let ctx = InsertContext {
180 buffer: &buffer,
181 cursor: Position::new(0, 3),
182 anchor: Position::ZERO,
183 query: "foo",
184 trigger: &CompletionTrigger::Manual,
185 case_sensitive: false,
186 language: "",
187 tree_sitter_symbols: &[],
188 path_context: false,
189 buffer_dir: Some(std::path::Path::new(".")),
190 uri: None,
191 lsp_position: None,
192 };
193 assert!(PathCompletionSource.produce(&ctx).is_empty());
194 }
195
196 #[test]
197 fn lists_dir_entries_when_in_path_context() {
198 let tmp = std::env::temp_dir().join(format!("lattice-csm7-{}", std::process::id()));
199 let _ = fs::remove_dir_all(&tmp);
200 fs::create_dir_all(&tmp).unwrap();
201 fs::write(tmp.join("alpha.txt"), "").unwrap();
202 fs::create_dir(tmp.join("subdir")).unwrap();
203 let buffer = make_buffer("\"a");
204 let ctx = InsertContext {
205 buffer: &buffer,
206 cursor: Position::new(0, 2),
207 anchor: Position::new(0, 1),
208 query: "a",
209 trigger: &CompletionTrigger::Manual,
210 case_sensitive: false,
211 language: "",
212 tree_sitter_symbols: &[],
213 path_context: true,
214 buffer_dir: Some(&tmp),
215 uri: None,
216 lsp_position: None,
217 };
218 let candidates = PathCompletionSource.produce(&ctx);
219 let labels: Vec<_> = candidates.iter().map(|c| c.display.clone()).collect();
220 assert!(labels.iter().any(|d| d == "alpha.txt"), "got {labels:?}");
221 assert!(labels.iter().any(|d| d == "subdir/"), "got {labels:?}");
222 let _ = fs::remove_dir_all(&tmp);
223 }
224
225 #[test]
226 fn ignored_names_are_skipped() {
227 let tmp = std::env::temp_dir().join(format!("lattice-csm7-ign-{}", std::process::id()));
228 let _ = fs::remove_dir_all(&tmp);
229 fs::create_dir_all(&tmp).unwrap();
230 fs::create_dir(tmp.join(".git")).unwrap();
231 fs::create_dir(tmp.join("node_modules")).unwrap();
232 fs::write(tmp.join("readme.md"), "").unwrap();
233 let buffer = make_buffer("\"");
234 let ctx = InsertContext {
235 buffer: &buffer,
236 cursor: Position::new(0, 1),
237 anchor: Position::new(0, 1),
238 query: "",
239 trigger: &CompletionTrigger::Manual,
240 case_sensitive: false,
241 language: "",
242 tree_sitter_symbols: &[],
243 path_context: true,
244 buffer_dir: Some(&tmp),
245 uri: None,
246 lsp_position: None,
247 };
248 let candidates = PathCompletionSource.produce(&ctx);
249 let labels: Vec<_> = candidates.iter().map(|c| c.display.clone()).collect();
250 assert!(labels.iter().any(|d| d == "readme.md"));
251 assert!(!labels.iter().any(|d| d == ".git/"));
252 assert!(!labels.iter().any(|d| d == "node_modules/"));
253 let _ = fs::remove_dir_all(&tmp);
254 }
255}