mcpls_core/bridge/
state.rs

1//! Document state management.
2//!
3//! Tracks open documents and their versions for LSP synchronization.
4
5use 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/// State of a single document.
14#[derive(Debug, Clone)]
15pub struct DocumentState {
16    /// Document URI.
17    pub uri: Uri,
18    /// Language identifier.
19    pub language_id: String,
20    /// Document version (monotonically increasing).
21    pub version: i32,
22    /// Document content.
23    pub content: String,
24}
25
26/// Resource limits for document tracking.
27#[derive(Debug, Clone, Copy)]
28pub struct ResourceLimits {
29    /// Maximum number of open documents (0 = unlimited).
30    pub max_documents: usize,
31    /// Maximum file size in bytes (0 = unlimited).
32    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, // 10MB
40        }
41    }
42}
43
44/// Tracks document state across the workspace.
45#[derive(Debug)]
46pub struct DocumentTracker {
47    /// Open documents by file path.
48    documents: HashMap<PathBuf, DocumentState>,
49    /// Resource limits for tracking.
50    limits: ResourceLimits,
51}
52
53impl Default for DocumentTracker {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl DocumentTracker {
60    /// Create a new document tracker with default limits.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::with_limits(ResourceLimits::default())
64    }
65
66    /// Create a new document tracker with custom limits.
67    #[must_use]
68    pub fn with_limits(limits: ResourceLimits) -> Self {
69        Self {
70            documents: HashMap::new(),
71            limits,
72        }
73    }
74
75    /// Check if a document is currently open.
76    #[must_use]
77    pub fn is_open(&self, path: &Path) -> bool {
78        self.documents.contains_key(path)
79    }
80
81    /// Get the state of an open document.
82    #[must_use]
83    pub fn get(&self, path: &Path) -> Option<&DocumentState> {
84        self.documents.get(path)
85    }
86
87    /// Get the number of open documents.
88    #[must_use]
89    pub fn len(&self) -> usize {
90        self.documents.len()
91    }
92
93    /// Check if there are no open documents.
94    #[must_use]
95    pub fn is_empty(&self) -> bool {
96        self.documents.is_empty()
97    }
98
99    /// Open a document and track its state.
100    ///
101    /// Returns the document URI for use in LSP requests.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if:
106    /// - Document limit is exceeded
107    /// - File size limit is exceeded
108    pub fn open(&mut self, path: PathBuf, content: String) -> Result<Uri> {
109        // Check document limit
110        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        // Check file size limit
118        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    /// Update a document's content and increment its version.
141    ///
142    /// Returns `None` if the document is not open.
143    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    /// Close a document and remove it from tracking.
154    ///
155    /// Returns the document state if it was open.
156    pub fn close(&mut self, path: &Path) -> Option<DocumentState> {
157        self.documents.remove(path)
158    }
159
160    /// Close all documents.
161    pub fn close_all(&mut self) -> Vec<DocumentState> {
162        self.documents.drain().map(|(_, state)| state).collect()
163    }
164
165    /// Ensure a document is open, opening it lazily if necessary.
166    ///
167    /// If the document is already open, returns its URI immediately.
168    /// Otherwise, reads the file from disk, opens it in the tracker,
169    /// and sends a `didOpen` notification to the LSP server.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if:
174    /// - The file cannot be read from disk
175    /// - The `didOpen` notification fails to send
176    /// - Resource limits are exceeded
177    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/// Convert a file path to a URI.
211#[must_use]
212pub fn path_to_uri(path: &Path) -> Uri {
213    // Convert path to file:// URI string and parse
214    let uri_string = if cfg!(windows) {
215        format!("file:///{}", path.display().to_string().replace('\\', "/"))
216    } else {
217        format!("file://{}", path.display())
218    };
219    // Path-to-URI conversion should always succeed for valid paths
220    #[allow(clippy::expect_used)]
221    uri_string.parse().expect("failed to create URI from path")
222}
223
224/// Detect the language ID from a file path.
225#[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        // First two documents should succeed
309        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        // Third should fail
317        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        // Small file should succeed
330        tracker
331            .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
332            .unwrap();
333
334        // Large file should fail
335        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}