Skip to main content

krait/lsp/
files.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5use tracing::debug;
6
7use super::client::path_to_uri;
8use super::transport::LspTransport;
9use crate::detect::Language;
10
11/// Tracks which files are open in the LSP server.
12///
13/// LSP requires `textDocument/didOpen` before any queries on a file.
14/// This tracker ensures idempotent opens and bulk close on shutdown.
15pub struct FileTracker {
16    open_files: HashSet<PathBuf>,
17    language: Language,
18}
19
20impl FileTracker {
21    /// Create a new tracker for the given language.
22    #[must_use]
23    pub fn new(language: Language) -> Self {
24        Self {
25            open_files: HashSet::new(),
26            language,
27        }
28    }
29
30    /// Ensure a file is open in the LSP server.
31    /// If already open, this is a no-op.
32    ///
33    /// # Errors
34    /// Returns an error if the file can't be read or the notification fails.
35    pub async fn ensure_open(
36        &mut self,
37        path: &Path,
38        transport: &mut LspTransport,
39    ) -> anyhow::Result<()> {
40        let canonical = std::fs::canonicalize(path)
41            .with_context(|| format!("file not found: {}", path.display()))?;
42
43        if self.open_files.contains(&canonical) {
44            return Ok(());
45        }
46
47        let uri = path_to_uri(&canonical)?;
48        let text = std::fs::read_to_string(&canonical)
49            .with_context(|| format!("failed to read: {}", canonical.display()))?;
50
51        let params = serde_json::json!({
52            "textDocument": {
53                "uri": uri.as_str(),
54                "languageId": self.language.name(),
55                "version": 0,
56                "text": text,
57            }
58        });
59
60        transport
61            .send_notification("textDocument/didOpen", params)
62            .await?;
63
64        debug!("opened file: {}", canonical.display());
65        self.open_files.insert(canonical);
66        Ok(())
67    }
68
69    /// Open a file with pre-read content (avoids duplicate disk I/O during indexing).
70    ///
71    /// # Errors
72    /// Returns an error if the notification fails.
73    pub async fn open_with_content(
74        &mut self,
75        path: &Path,
76        uri: &str,
77        content: &str,
78        transport: &mut LspTransport,
79    ) -> anyhow::Result<()> {
80        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
81
82        if self.open_files.contains(&canonical) {
83            return Ok(());
84        }
85
86        let params = serde_json::json!({
87            "textDocument": {
88                "uri": uri,
89                "languageId": self.language.name(),
90                "version": 0,
91                "text": content,
92            }
93        });
94
95        transport
96            .send_notification("textDocument/didOpen", params)
97            .await?;
98
99        self.open_files.insert(canonical);
100        Ok(())
101    }
102
103    /// Force-reopen a file, sending fresh content to the LSP.
104    ///
105    /// Unlike `ensure_open`, this always sends `didClose` (if already open) followed by
106    /// `didOpen` with the current on-disk content. Use this after a file has been edited
107    /// so the language server analyses the new version.
108    ///
109    /// # Errors
110    /// Returns an error if the file can't be read or the notification fails.
111    pub async fn reopen(
112        &mut self,
113        path: &Path,
114        transport: &mut LspTransport,
115    ) -> anyhow::Result<()> {
116        let canonical = std::fs::canonicalize(path)
117            .with_context(|| format!("file not found: {}", path.display()))?;
118
119        // Close first (no-op if not open, but always remove from tracker)
120        if self.open_files.remove(&canonical) {
121            let uri = path_to_uri(&canonical)?;
122            let params = serde_json::json!({
123                "textDocument": { "uri": uri.as_str() }
124            });
125            transport
126                .send_notification("textDocument/didClose", params)
127                .await?;
128            debug!("closed (for reopen): {}", canonical.display());
129        }
130
131        // Now open with fresh content
132        self.ensure_open(path, transport).await
133    }
134
135    /// Close a file in the LSP server.
136    ///
137    /// # Errors
138    /// Returns an error if the notification fails.
139    pub async fn close(&mut self, path: &Path, transport: &mut LspTransport) -> anyhow::Result<()> {
140        let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
141
142        if !self.open_files.remove(&canonical) {
143            return Ok(());
144        }
145
146        let uri = path_to_uri(&canonical)?;
147        let params = serde_json::json!({
148            "textDocument": {
149                "uri": uri.as_str(),
150            }
151        });
152
153        transport
154            .send_notification("textDocument/didClose", params)
155            .await?;
156
157        debug!("closed file: {}", canonical.display());
158        Ok(())
159    }
160
161    /// Close all open files.
162    ///
163    /// # Errors
164    /// Returns an error if any close notification fails.
165    pub async fn close_all(&mut self, transport: &mut LspTransport) -> anyhow::Result<()> {
166        let paths: Vec<PathBuf> = self.open_files.drain().collect();
167        for path in &paths {
168            let uri = path_to_uri(path)?;
169            let params = serde_json::json!({
170                "textDocument": {
171                    "uri": uri.as_str(),
172                }
173            });
174            transport
175                .send_notification("textDocument/didClose", params)
176                .await?;
177            debug!("closed file: {}", path.display());
178        }
179        Ok(())
180    }
181
182    /// Check if a file is currently open.
183    #[must_use]
184    pub fn is_open(&self, path: &Path) -> bool {
185        std::fs::canonicalize(path)
186            .map(|c| self.open_files.contains(&c))
187            .unwrap_or(false)
188    }
189
190    /// Number of currently open files.
191    #[must_use]
192    pub fn open_count(&self) -> usize {
193        self.open_files.len()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn new_tracker_is_empty() {
203        let tracker = FileTracker::new(Language::Rust);
204        assert_eq!(tracker.open_count(), 0);
205    }
206
207    #[test]
208    fn is_open_returns_false_for_unknown_file() {
209        let tracker = FileTracker::new(Language::Rust);
210        assert!(!tracker.is_open(Path::new("/tmp/nonexistent.rs")));
211    }
212}