Skip to main content

lattice_completion/
path.rs

1//! Path-completion source (CSM.7).
2//!
3//! Fires when `InsertContext::path_context` is true -- the host
4//! sets the flag when the cursor sits inside a tree-sitter
5//! string scope. The source walks the directory referenced by
6//! the partial path the user typed, emits one candidate per
7//! filesystem entry (capped, ignored-name set filtered).
8//!
9//! No internal cache in v1 -- the cache that lived host-side
10//! (`App.path_completion_cache`) retired with this slice. Each
11//! popup-open / refilter pays one `read_dir` + per-entry
12//! `file_type()`; profile and reintroduce a source-internal
13//! `Mutex<Option<...>>` if the keystroke budget regresses.
14//!
15//! Mode adapter (`PathCompletionMode`) lives in
16//! `lattice-mode::modes::completion` due to the
17//! `lattice-mode <- lattice-completion` dep direction; the
18//! adapter just wraps an `Arc<PathCompletionSource>`.
19
20use std::path::PathBuf;
21
22use crate::candidate::{CandidateData, CandidateKind, RawCandidate};
23use crate::insert::{InsertContext, PATH_SOURCE_ID, SourceId};
24use crate::source::SyncCompletionSource;
25
26/// Hardcoded ignore set for v1 path completion. `.gitignore`
27/// integration queues for a follow-up (needs the `ignore` crate
28/// + workspace-root resolution).
29const IGNORE_NAMES: &[&str] = &[".git", "node_modules", "target", "dist"];
30
31/// Cap on candidates emitted per popup-open. Very large
32/// directories would otherwise saturate the popup; the
33/// matcher's typed prefix narrows things down anyway.
34const MAX_ENTRIES: usize = 200;
35
36/// The `SyncCompletionSource` impl for path completion.
37/// Stateless beyond its newtype identity; the host threads
38/// `path_context` + `buffer_dir` through `InsertContext`.
39#[derive(Debug, Clone, Default)]
40pub struct PathCompletionSource;
41
42impl SyncCompletionSource for PathCompletionSource {
43    fn produce(&self, ctx: &InsertContext<'_>) -> Vec<RawCandidate> {
44        // Suppression: outside a string scope, do nothing. The
45        // popup's all-sources view stays free of filenames in
46        // prose / code.
47        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        // Walk back over path-shaped bytes (NOT stopping at `/`)
55        // to recover the full partial path the user typed inside
56        // the string literal. The trigger anchor in
57        // `do_completion_trigger` stops at `/`; here we want the
58        // full thing so we know which directory to walk.
59        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], // keep trailing slash
74            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            // Dotfiles are filtered by default -- they're rarely
108            // the user's typed-context-correct candidate. A
109            // future `completion.show_hidden` typed option could
110            // surface them.
111            if name.starts_with('.') {
112                continue;
113            }
114            let display = if is_dir {
115                format!("{name}/")
116            } else {
117                name.clone()
118            };
119            // For directories, the inserted text includes the
120            // trailing `/` so the user can keep tab-completing
121            // into nested paths (mirrors `gen:files` for the
122            // cmdline).
123            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
149/// Same byte-class check as the host's `is_path_byte` --
150/// duplicated here so the source doesn't reach into host
151/// internals. Identifier chars plus `.` and `-` for filenames
152/// like `foo.txt` and `my-script`.
153fn 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}