tinymist_query/analysis/completion/
path.rs1use std::collections::HashSet;
4use std::path::Component;
5
6use tinymist_project::EntryReader;
7use tinymist_world::{ShadowApi, vfs::WorkspaceResolver};
8
9use super::*;
10impl CompletionPair<'_, '_, '_> {
11 pub fn complete_path(&mut self, preference: &PathKind) -> Option<Vec<CompletionItem>> {
12 let id = self.cursor.source.id();
13 if WorkspaceResolver::is_package_file(id) {
14 return None;
15 }
16
17 let (is_in_text, raw_text, text, full_text, rng) = if self.cursor.leaf.is::<ast::Str>() {
18 let vr = self.cursor.leaf.range();
19 let rng = vr.start + 1..vr.end - 1;
20 if rng.start > rng.end
21 || (self.cursor.cursor != rng.end && !rng.contains(&self.cursor.cursor))
22 {
23 return None;
24 }
25
26 let raw_text = &self.cursor.text[rng.start..self.cursor.cursor];
27 let raw_full_text = &self.cursor.text[rng.clone()];
28 (
29 true,
30 raw_text,
31 string_prefix_lossy(raw_text),
32 string_prefix_lossy(raw_full_text),
33 rng,
34 )
35 } else {
36 (
37 false,
38 "",
39 EcoString::default(),
40 EcoString::default(),
41 self.cursor.cursor..self.cursor.cursor,
42 )
43 };
44 crate::log_debug_ct!("complete_path: is_in_text: {is_in_text:?}");
45
46 let path_text = text.as_str();
47 let path = Path::new(path_text);
48 let has_root = path.has_root();
49
50 let base = id;
51 let base_dir = base
52 .vpath()
53 .as_rooted_path_compat()
54 .parent()
55 .unwrap_or(Path::new("/"));
56 let Ok(resolved_path) = resolve_path_from_id(id, path.to_str()?) else {
57 return Some(vec![]);
58 };
59 if resolve_path_from_id(id, full_text.as_str()).is_err() {
60 return Some(vec![]);
61 }
62
63 let resolved_path = resolved_path.vpath().as_rootless_path_compat();
64 let (directory_to_list, filter_prefix) = path_completion_target(
65 resolved_path,
66 path_text,
67 full_text.as_str(),
68 has_root,
69 base_dir,
70 )?;
71 crate::log_debug_ct!("directory_to_list: {path:?} -> {directory_to_list:?}");
72
73 let mut seen_entries = HashSet::new();
74 let mut folder_completions = vec![];
75 let mut module_completions = vec![];
76 let base_path = normalize_rootless_path(base.vpath().as_rootless_path_compat());
77 let mut push_completion = |entry_path: &Path, kind: CompletionKind| {
78 if entry_path == base_path {
79 return;
80 }
81
82 let is_folder = matches!(kind, CompletionKind::Folder);
83 let Some(label) = completion_label(entry_path, has_root, is_folder, base_dir) else {
84 return;
85 };
86 if !filter_prefix.is_empty() && !label.starts_with(filter_prefix.as_str()) {
87 return;
88 }
89 if !seen_entries.insert(label.clone()) {
90 return;
91 }
92
93 if is_folder {
94 folder_completions.push((label, kind));
95 } else {
96 module_completions.push((label, kind));
97 }
98 };
99
100 for (entry_path, kind) in self.directory_entries(&directory_to_list) {
101 push_completion(&entry_path, kind);
102 }
103
104 for file in self.worker.ctx.completion_files(preference) {
105 crate::log_debug_ct!("compl_check_path: {file:?}");
106
107 if *file == base {
108 continue;
109 }
110
111 let file_path = normalize_rootless_path(file.vpath().as_rootless_path_compat());
112 push_completion(&file_path, CompletionKind::File);
113 }
114
115 let replace_range = self.cursor.lsp_range_of(rng);
116
117 fn is_dot_or_slash(ch: &char) -> bool {
118 matches!(*ch, '.' | '/')
119 }
120
121 let path_priority_cmp = |lhs: &str, rhs: &str| {
122 if lhs.starts_with('.') || rhs.starts_with('.') {
124 let a_prefix = lhs.chars().take_while(is_dot_or_slash).count();
126 let b_prefix = rhs.chars().take_while(is_dot_or_slash).count();
127 if a_prefix != b_prefix {
128 return a_prefix.cmp(&b_prefix);
129 }
130 }
131 lhs.cmp(rhs)
132 };
133
134 module_completions.sort_by(|a, b| path_priority_cmp(&a.0, &b.0));
135 folder_completions.sort_by(|a, b| path_priority_cmp(&a.0, &b.0));
136
137 let mut sorter = 0;
138 let digits = (module_completions.len() + folder_completions.len())
139 .to_string()
140 .len();
141 let completions = folder_completions.into_iter().chain(module_completions);
142 Some(
143 completions
144 .map(|(label, kind)| {
145 let is_folder = matches!(kind, CompletionKind::Folder);
146 let lsp_snippet = label.clone();
147 let text_edit = EcoTextEdit::new(
148 replace_range,
149 if is_in_text {
150 lsp_snippet
151 } else {
152 eco_format!(r#""{lsp_snippet}""#)
153 },
154 );
155
156 let sort_text = eco_format!("{sorter:0>digits$}");
157 sorter += 1;
158
159 LspCompletion {
161 filter_text: completion_filter_text(&label, &filter_prefix, raw_text),
162 label,
163 kind,
164 detail: None,
165 text_edit: Some(text_edit.into()),
166 sort_text: Some(sort_text),
168 insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
169 command: self
170 .worker
171 .ctx
172 .analysis
173 .trigger_suggest(is_folder)
174 .map(From::from),
175 ..Default::default()
176 }
177 })
178 .collect_vec(),
179 )
180 }
181
182 fn directory_entries(&self, dir: &Path) -> Vec<(PathBuf, CompletionKind)> {
183 let Some(root) = self.worker.ctx.world().entry_state().workspace_root() else {
184 return vec![];
185 };
186
187 let dir = normalize_rootless_path(dir);
188 let mut entries = vec![];
189
190 #[cfg(not(target_arch = "wasm32"))]
191 if let Some(read_dir) = self
192 .worker
193 .ctx
194 .analysis
195 .completion_feat
196 .path_completion_by_filesystem
197 .then(|| std::fs::read_dir(root.join(&dir)))
198 .and_then(Result::ok)
199 {
200 entries.extend(read_dir.flatten().filter_map(|entry| {
201 let path = entry.path();
202 let rootless = path.strip_prefix(root.as_ref()).ok()?;
203 let kind = if entry.file_type().ok()?.is_dir() {
204 CompletionKind::Folder
205 } else {
206 CompletionKind::File
207 };
208 Some((normalize_rootless_path(rootless), kind))
209 }));
210 }
211
212 for shadow_path in self.worker.world().shadow_paths() {
213 let Ok(rootless) = shadow_path.strip_prefix(root.as_ref()) else {
214 continue;
215 };
216 if let Some(entry) = shadow_file_to_dir_entry(rootless, &dir) {
217 entries.push(entry);
218 }
219 }
220
221 entries
222 }
223}
224
225fn string_prefix_lossy(raw: &str) -> EcoString {
226 let mut w = EcoString::new();
227 w.push('"');
228 w.push_str(raw);
229 w.push('"');
230 let partial_str = SyntaxNode::leaf(SyntaxKind::Str, w);
231 partial_str
232 .cast::<ast::Str>()
233 .map(|s| s.get())
234 .unwrap_or_default()
235}
236
237fn shadow_file_to_dir_entry(
238 file_path: &Path,
239 current_dir: &Path,
240) -> Option<(PathBuf, CompletionKind)> {
241 let file_path = normalize_rootless_path(file_path);
242 let current_dir = normalize_rootless_path(current_dir);
243 if file_path.parent().unwrap_or(Path::new("")) == current_dir {
244 return Some((file_path, CompletionKind::File));
245 }
246
247 let relative = if current_dir.as_os_str().is_empty() {
248 file_path.as_path()
249 } else {
250 file_path.strip_prefix(¤t_dir).ok()?
251 };
252 let mut components = relative.components();
253 let Component::Normal(first) = components.next()? else {
254 return None;
255 };
256 components.next()?;
257
258 Some((current_dir.join(first), CompletionKind::Folder))
259}
260
261fn completion_label(
262 rootless_path: &Path,
263 has_root: bool,
264 is_folder: bool,
265 base_dir: &Path,
266) -> Option<EcoString> {
267 let rooted_path = Path::new("/").join(rootless_path);
268 let label_path = if has_root {
269 rooted_path
270 } else {
271 tinymist_std::path::diff(&rooted_path, base_dir)?
272 };
273 let mut label: EcoString = unix_slash(&label_path).into();
274 if is_folder && !label.ends_with('/') {
275 label.push('/');
276 }
277 Some(label)
278}
279
280fn path_completion_target(
281 resolved_prefix: &Path,
282 typed_prefix: &str,
283 whole_string: &str,
284 has_root: bool,
285 base_dir: &Path,
286) -> Option<(PathBuf, EcoString)> {
287 let prefix_names_dir = path_prefix_names_directory(typed_prefix, whole_string);
288 let directory = if prefix_names_dir {
289 resolved_prefix.to_owned()
290 } else {
291 resolved_prefix.parent().unwrap_or(Path::new("")).to_owned()
292 };
293
294 let mut label_prefix = completion_label(resolved_prefix, has_root, false, base_dir)?;
295 if label_prefix == "." {
296 label_prefix.clear();
297 }
298 if prefix_names_dir && !label_prefix.is_empty() && !label_prefix.ends_with('/') {
299 label_prefix.push('/');
300 }
301
302 Some((directory, label_prefix))
303}
304
305fn path_prefix_names_directory(typed_prefix: &str, whole_string: &str) -> bool {
309 if typed_prefix.is_empty() {
310 return whole_string.is_empty();
311 }
312
313 typed_prefix.ends_with('/') || typed_prefix == "." || typed_prefix.ends_with("/.")
314}
315
316fn completion_filter_text(label: &str, prefix: &str, raw_prefix: &str) -> Option<EcoString> {
317 let filter_text = label
318 .strip_prefix(prefix)
319 .map(|suffix| eco_format!("{raw_prefix}{suffix}"))
320 .unwrap_or_else(|| label.into());
321
322 (filter_text.as_str() != label).then_some(filter_text)
323}
324
325fn normalize_rootless_path(path: &Path) -> PathBuf {
326 let mut normalized = PathBuf::new();
327 for component in path.components() {
328 match component {
329 Component::CurDir => {}
330 Component::Normal(component) => normalized.push(component),
331 Component::ParentDir => {
332 normalized.pop();
333 }
334 Component::Prefix(_) | Component::RootDir => {}
335 }
336 }
337 normalized
338}
339
340#[cfg(test)]
341mod tests {
342 use super::string_prefix_lossy;
343
344 #[test]
345 fn string_prefix_lossy_normal_string() {
346 assert_eq!(
347 string_prefix_lossy("assets/logo.png").as_str(),
348 "assets/logo.png"
349 );
350 assert_eq!(
351 string_prefix_lossy(r#"assets/\u{6c}ogo.png"#).as_str(),
352 "assets/logo.png"
353 );
354 }
355
356 #[test]
357 fn string_prefix_lossy_incomplete_escape() {
358 assert_eq!(string_prefix_lossy(r#"assets\"#).as_str(), r#"assets\"#);
359 assert_eq!(
360 string_prefix_lossy(r#"assets/\u{"#).as_str(),
361 r#"assets/\u{"#
362 );
363 }
364
365 #[test]
366 fn string_prefix_lossy_syntax_error_text() {
367 assert_eq!(
368 string_prefix_lossy(r#"assets/"bad"#).as_str(),
369 r#"assets/"bad"#
370 );
371 assert_eq!(
372 string_prefix_lossy(r#"assets/\u{zz}"#).as_str(),
373 r#"assets/\u{zz}"#
374 );
375 }
376}