1use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use lsp_types::{DidOpenTextDocumentParams, TextDocumentItem, Uri};
9
10use crate::error::{Error, Result};
11use crate::lsp::LspClient;
12
13#[derive(Debug, Clone)]
15pub struct DocumentState {
16 pub uri: Uri,
18 pub language_id: String,
20 pub version: i32,
22 pub content: String,
24}
25
26#[derive(Debug, Clone, Copy)]
28pub struct ResourceLimits {
29 pub max_documents: usize,
31 pub max_file_size: u64,
33}
34
35impl Default for ResourceLimits {
36 fn default() -> Self {
37 Self {
38 max_documents: 100,
39 max_file_size: 10 * 1024 * 1024, }
41 }
42}
43
44#[derive(Debug)]
46pub struct DocumentTracker {
47 documents: HashMap<PathBuf, DocumentState>,
49 limits: ResourceLimits,
51}
52
53impl Default for DocumentTracker {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl DocumentTracker {
60 #[must_use]
62 pub fn new() -> Self {
63 Self::with_limits(ResourceLimits::default())
64 }
65
66 #[must_use]
68 pub fn with_limits(limits: ResourceLimits) -> Self {
69 Self {
70 documents: HashMap::new(),
71 limits,
72 }
73 }
74
75 #[must_use]
77 pub fn is_open(&self, path: &Path) -> bool {
78 self.documents.contains_key(path)
79 }
80
81 #[must_use]
83 pub fn get(&self, path: &Path) -> Option<&DocumentState> {
84 self.documents.get(path)
85 }
86
87 #[must_use]
89 pub fn len(&self) -> usize {
90 self.documents.len()
91 }
92
93 #[must_use]
95 pub fn is_empty(&self) -> bool {
96 self.documents.is_empty()
97 }
98
99 pub fn open(&mut self, path: PathBuf, content: String) -> Result<Uri> {
109 if self.limits.max_documents > 0 && self.documents.len() >= self.limits.max_documents {
111 return Err(Error::DocumentLimitExceeded {
112 current: self.documents.len(),
113 max: self.limits.max_documents,
114 });
115 }
116
117 let size = content.len() as u64;
119 if self.limits.max_file_size > 0 && size > self.limits.max_file_size {
120 return Err(Error::FileSizeLimitExceeded {
121 size,
122 max: self.limits.max_file_size,
123 });
124 }
125
126 let uri = path_to_uri(&path);
127 let language_id = detect_language(&path);
128
129 let state = DocumentState {
130 uri: uri.clone(),
131 language_id,
132 version: 1,
133 content,
134 };
135
136 self.documents.insert(path, state);
137 Ok(uri)
138 }
139
140 pub fn update(&mut self, path: &Path, content: String) -> Option<i32> {
144 if let Some(state) = self.documents.get_mut(path) {
145 state.version += 1;
146 state.content = content;
147 Some(state.version)
148 } else {
149 None
150 }
151 }
152
153 pub fn close(&mut self, path: &Path) -> Option<DocumentState> {
157 self.documents.remove(path)
158 }
159
160 pub fn close_all(&mut self) -> Vec<DocumentState> {
162 self.documents.drain().map(|(_, state)| state).collect()
163 }
164
165 pub async fn ensure_open(&mut self, path: &Path, lsp_client: &LspClient) -> Result<Uri> {
178 if let Some(state) = self.documents.get(path) {
179 return Ok(state.uri.clone());
180 }
181
182 let content = tokio::fs::read_to_string(path)
183 .await
184 .map_err(|e| Error::FileIo {
185 path: path.to_path_buf(),
186 source: e,
187 })?;
188
189 let uri = self.open(path.to_path_buf(), content.clone())?;
190 let state = self
191 .documents
192 .get(path)
193 .ok_or_else(|| Error::DocumentNotFound(path.to_path_buf()))?;
194
195 let params = DidOpenTextDocumentParams {
196 text_document: TextDocumentItem {
197 uri: uri.clone(),
198 language_id: state.language_id.clone(),
199 version: state.version,
200 text: content,
201 },
202 };
203
204 lsp_client.notify("textDocument/didOpen", params).await?;
205
206 Ok(uri)
207 }
208}
209
210#[must_use]
212pub fn path_to_uri(path: &Path) -> Uri {
213 let uri_string = if cfg!(windows) {
215 format!("file:///{}", path.display().to_string().replace('\\', "/"))
216 } else {
217 format!("file://{}", path.display())
218 };
219 #[allow(clippy::expect_used)]
221 uri_string.parse().expect("failed to create URI from path")
222}
223
224#[must_use]
226pub fn detect_language(path: &Path) -> String {
227 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
228
229 match extension {
230 "rs" => "rust",
231 "py" | "pyw" | "pyi" => "python",
232 "js" | "mjs" | "cjs" => "javascript",
233 "ts" | "mts" | "cts" => "typescript",
234 "tsx" => "typescriptreact",
235 "jsx" => "javascriptreact",
236 "go" => "go",
237 "c" | "h" => "c",
238 "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
239 "java" => "java",
240 "rb" => "ruby",
241 "php" => "php",
242 "swift" => "swift",
243 "kt" | "kts" => "kotlin",
244 "scala" | "sc" => "scala",
245 "zig" => "zig",
246 "lua" => "lua",
247 "sh" | "bash" | "zsh" => "shellscript",
248 "json" => "json",
249 "toml" => "toml",
250 "yaml" | "yml" => "yaml",
251 "xml" => "xml",
252 "html" | "htm" => "html",
253 "css" => "css",
254 "scss" => "scss",
255 "less" => "less",
256 "md" | "markdown" => "markdown",
257 _ => "plaintext",
258 }
259 .to_string()
260}
261
262#[cfg(test)]
263#[allow(clippy::unwrap_used)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn test_detect_language() {
269 assert_eq!(detect_language(Path::new("main.rs")), "rust");
270 assert_eq!(detect_language(Path::new("script.py")), "python");
271 assert_eq!(detect_language(Path::new("app.ts")), "typescript");
272 assert_eq!(detect_language(Path::new("unknown.xyz")), "plaintext");
273 }
274
275 #[test]
276 fn test_document_tracker() {
277 let mut tracker = DocumentTracker::new();
278 let path = PathBuf::from("/test/file.rs");
279
280 assert!(!tracker.is_open(&path));
281
282 tracker
283 .open(path.clone(), "fn main() {}".to_string())
284 .unwrap();
285 assert!(tracker.is_open(&path));
286 assert_eq!(tracker.len(), 1);
287
288 let state = tracker.get(&path).unwrap();
289 assert_eq!(state.version, 1);
290 assert_eq!(state.language_id, "rust");
291
292 let new_version = tracker.update(&path, "fn main() { println!() }".to_string());
293 assert_eq!(new_version, Some(2));
294
295 tracker.close(&path);
296 assert!(!tracker.is_open(&path));
297 assert!(tracker.is_empty());
298 }
299
300 #[test]
301 fn test_document_limit() {
302 let limits = ResourceLimits {
303 max_documents: 2,
304 max_file_size: 100,
305 };
306 let mut tracker = DocumentTracker::with_limits(limits);
307
308 tracker
310 .open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
311 .unwrap();
312 tracker
313 .open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
314 .unwrap();
315
316 let result = tracker.open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string());
318 assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
319 }
320
321 #[test]
322 fn test_file_size_limit() {
323 let limits = ResourceLimits {
324 max_documents: 10,
325 max_file_size: 10,
326 };
327 let mut tracker = DocumentTracker::with_limits(limits);
328
329 tracker
331 .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
332 .unwrap();
333
334 let large_content = "x".repeat(100);
336 let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
337 assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
338 }
339}