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
11pub struct FileTracker {
16 open_files: HashSet<PathBuf>,
17 language: Language,
18}
19
20impl FileTracker {
21 #[must_use]
23 pub fn new(language: Language) -> Self {
24 Self {
25 open_files: HashSet::new(),
26 language,
27 }
28 }
29
30 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 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 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 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 self.ensure_open(path, transport).await
133 }
134
135 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 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 #[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 #[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}