tinymist_query/analysis/completion/
path.rs

1//! Completion of paths (string literal).
2
3use 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            // files are more important than dot started paths
123            if lhs.starts_with('.') || rhs.starts_with('.') {
124                // compare consecutive dots and slashes
125                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                    // todo: not all clients support label details
160                    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                        // don't sort me
167                        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(&current_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
305/// Decide from source text whether to list this path itself or its parent.
306///
307/// `Path` components drop trailing `.`, but `../pkg/.` should list `../pkg/`.
308fn 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}