Skip to main content

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};
9use url::Url;
10
11use crate::error::{Error, Result};
12use crate::lsp::LspClient;
13
14/// State of a single document.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct DocumentState {
17    /// Document URI.
18    pub uri: Uri,
19    /// Language identifier.
20    pub language_id: String,
21    /// Document version (monotonically increasing).
22    pub version: i32,
23    /// Document content.
24    pub content: String,
25}
26
27/// Resource limits for document tracking.
28#[derive(Debug, Clone, Copy)]
29pub struct ResourceLimits {
30    /// Maximum number of open documents (0 = unlimited).
31    pub max_documents: usize,
32    /// Maximum file size in bytes (0 = unlimited).
33    pub max_file_size: u64,
34}
35
36impl Default for ResourceLimits {
37    fn default() -> Self {
38        Self {
39            max_documents: 100,
40            max_file_size: 10 * 1024 * 1024, // 10MB
41        }
42    }
43}
44
45/// Tracks document state across the workspace.
46#[derive(Debug)]
47pub struct DocumentTracker {
48    /// Open documents by file path.
49    documents: HashMap<PathBuf, DocumentState>,
50    /// Resource limits for tracking.
51    limits: ResourceLimits,
52    /// Custom file extension to language ID mappings.
53    extension_map: HashMap<String, String>,
54}
55
56impl DocumentTracker {
57    /// Create a new document tracker with custom limits and extension mappings.
58    #[must_use]
59    pub fn new(limits: ResourceLimits, extension_map: HashMap<String, String>) -> Self {
60        Self {
61            documents: HashMap::new(),
62            limits,
63            extension_map,
64        }
65    }
66
67    /// Check if a document is currently open.
68    #[must_use]
69    pub fn is_open(&self, path: &Path) -> bool {
70        self.documents.contains_key(path)
71    }
72
73    /// Get the state of an open document.
74    #[must_use]
75    pub fn get(&self, path: &Path) -> Option<&DocumentState> {
76        self.documents.get(path)
77    }
78
79    /// Get the number of open documents.
80    #[must_use]
81    pub fn len(&self) -> usize {
82        self.documents.len()
83    }
84
85    /// Check if there are no open documents.
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.documents.is_empty()
89    }
90
91    /// Open a document and track its state.
92    ///
93    /// Returns the document URI for use in LSP requests.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if:
98    /// - Document limit is exceeded
99    /// - File size limit is exceeded
100    pub fn open(&mut self, path: PathBuf, content: String) -> Result<Uri> {
101        // Check document limit
102        if self.limits.max_documents > 0 && self.documents.len() >= self.limits.max_documents {
103            return Err(Error::DocumentLimitExceeded {
104                current: self.documents.len(),
105                max: self.limits.max_documents,
106            });
107        }
108
109        // Check file size limit
110        let size = content.len() as u64;
111        if self.limits.max_file_size > 0 && size > self.limits.max_file_size {
112            return Err(Error::FileSizeLimitExceeded {
113                size,
114                max: self.limits.max_file_size,
115            });
116        }
117
118        let uri = path_to_uri(&path);
119        let language_id = detect_language(&path, &self.extension_map);
120
121        let state = DocumentState {
122            uri: uri.clone(),
123            language_id,
124            version: 1,
125            content,
126        };
127
128        self.documents.insert(path, state);
129        Ok(uri)
130    }
131
132    /// Update a document's content and increment its version.
133    ///
134    /// Returns `None` if the document is not open.
135    pub fn update(&mut self, path: &Path, content: String) -> Option<i32> {
136        if let Some(state) = self.documents.get_mut(path) {
137            state.version += 1;
138            state.content = content;
139            Some(state.version)
140        } else {
141            None
142        }
143    }
144
145    /// Close a document and remove it from tracking.
146    ///
147    /// Returns the document state if it was open.
148    pub fn close(&mut self, path: &Path) -> Option<DocumentState> {
149        self.documents.remove(path)
150    }
151
152    /// Close all documents.
153    pub fn close_all(&mut self) -> Vec<DocumentState> {
154        self.documents.drain().map(|(_, state)| state).collect()
155    }
156
157    /// Iterate over the filesystem paths of all currently open documents.
158    pub fn open_paths(&self) -> impl Iterator<Item = &Path> {
159        self.documents.keys().map(PathBuf::as_path)
160    }
161
162    /// Ensure a document is open, opening it lazily if necessary.
163    ///
164    /// If the document is already open, returns its URI immediately.
165    /// Otherwise, reads the file from disk, opens it in the tracker,
166    /// and sends a `didOpen` notification to the LSP server.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if:
171    /// - The file cannot be read from disk
172    /// - The `didOpen` notification fails to send
173    /// - Resource limits are exceeded
174    pub async fn ensure_open(&mut self, path: &Path, lsp_client: &LspClient) -> Result<Uri> {
175        if let Some(state) = self.documents.get(path) {
176            return Ok(state.uri.clone());
177        }
178
179        let content = tokio::fs::read_to_string(path)
180            .await
181            .map_err(|e| Error::FileIo {
182                path: path.to_path_buf(),
183                source: e,
184            })?;
185
186        let uri = self.open(path.to_path_buf(), content.clone())?;
187        let state = self
188            .documents
189            .get(path)
190            .ok_or_else(|| Error::DocumentNotFound(path.to_path_buf()))?;
191
192        let params = DidOpenTextDocumentParams {
193            text_document: TextDocumentItem {
194                uri: uri.clone(),
195                language_id: state.language_id.clone(),
196                version: state.version,
197                text: content,
198            },
199        };
200
201        lsp_client.notify("textDocument/didOpen", params).await?;
202
203        Ok(uri)
204    }
205}
206
207/// Convert a file path to a URI.
208///
209/// # Panics
210///
211/// Panics if the path cannot be represented as a `file://` URI. This should
212/// not occur for valid absolute paths.
213#[must_use]
214pub fn path_to_uri(path: &Path) -> Uri {
215    let uri_string = if cfg!(windows) {
216        let path_str = path.to_string_lossy();
217        // canonicalize() on Windows adds a \\?\ extended-path prefix.
218        // Strip it before building the URI — file:////?\C:/ is not valid.
219        let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
220        format!("file:///{}", stripped.replace('\\', "/"))
221    } else {
222        format!("file://{}", path.display())
223    };
224    #[allow(clippy::expect_used)]
225    uri_string.parse().expect("failed to create URI from path")
226}
227
228/// Convert an LSP `file://` URI to an absolute filesystem path.
229///
230/// Returns `None` if the URI is not a valid `file://` URI, uses a non-file
231/// scheme, or contains percent-encoding that cannot map to a valid path.
232#[must_use]
233pub fn uri_to_path(uri: &Uri) -> Option<PathBuf> {
234    let url = Url::parse(uri.as_str()).ok()?;
235    if url.scheme() != "file" {
236        return None;
237    }
238    // Reject authority-bearing file URIs (e.g. `file://server/share`) to
239    // avoid UNC path confusion on Windows.
240    if !url.host_str().unwrap_or("").is_empty() {
241        return None;
242    }
243    url.to_file_path().ok()
244}
245
246/// Detect the language ID from a file path.
247///
248/// Consults the extension map to determine the language ID for a file.
249/// If the extension is not found in the map, returns "plaintext".
250#[must_use]
251pub fn detect_language(path: &Path, extension_map: &HashMap<String, String>) -> String {
252    let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
253
254    extension_map
255        .get(extension)
256        .cloned()
257        .unwrap_or_else(|| "plaintext".to_string())
258}
259
260#[cfg(test)]
261#[allow(clippy::unwrap_used)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_detect_language() {
267        let mut map = HashMap::new();
268        map.insert("rs".to_string(), "rust".to_string());
269        map.insert("py".to_string(), "python".to_string());
270        map.insert("ts".to_string(), "typescript".to_string());
271
272        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
273        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
274        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
275        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
276    }
277
278    #[test]
279    fn test_document_tracker() {
280        let mut map = HashMap::new();
281        map.insert("rs".to_string(), "rust".to_string());
282
283        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
284        let path = PathBuf::from("/test/file.rs");
285
286        assert!(!tracker.is_open(&path));
287
288        tracker
289            .open(path.clone(), "fn main() {}".to_string())
290            .unwrap();
291        assert!(tracker.is_open(&path));
292        assert_eq!(tracker.len(), 1);
293
294        let state = tracker.get(&path).unwrap();
295        assert_eq!(state.version, 1);
296        assert_eq!(state.language_id, "rust");
297
298        let new_version = tracker.update(&path, "fn main() { println!() }".to_string());
299        assert_eq!(new_version, Some(2));
300
301        tracker.close(&path);
302        assert!(!tracker.is_open(&path));
303        assert!(tracker.is_empty());
304    }
305
306    #[test]
307    fn test_document_limit() {
308        let limits = ResourceLimits {
309            max_documents: 2,
310            max_file_size: 100,
311        };
312        let mut map = HashMap::new();
313        map.insert("rs".to_string(), "rust".to_string());
314
315        let mut tracker = DocumentTracker::new(limits, map);
316
317        // First two documents should succeed
318        tracker
319            .open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
320            .unwrap();
321        tracker
322            .open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
323            .unwrap();
324
325        // Third should fail
326        let result = tracker.open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string());
327        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
328    }
329
330    #[test]
331    fn test_file_size_limit() {
332        let limits = ResourceLimits {
333            max_documents: 10,
334            max_file_size: 10,
335        };
336        let mut map = HashMap::new();
337        map.insert("rs".to_string(), "rust".to_string());
338
339        let mut tracker = DocumentTracker::new(limits, map);
340
341        // Small file should succeed
342        tracker
343            .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
344            .unwrap();
345
346        // Large file should fail
347        let large_content = "x".repeat(100);
348        let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
349        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
350    }
351
352    #[test]
353    fn test_resource_limits_default() {
354        let limits = ResourceLimits::default();
355        assert_eq!(limits.max_documents, 100);
356        assert_eq!(limits.max_file_size, 10 * 1024 * 1024);
357    }
358
359    #[test]
360    fn test_resource_limits_custom() {
361        let limits = ResourceLimits {
362            max_documents: 50,
363            max_file_size: 5 * 1024 * 1024,
364        };
365        assert_eq!(limits.max_documents, 50);
366        assert_eq!(limits.max_file_size, 5 * 1024 * 1024);
367    }
368
369    #[test]
370    fn test_resource_limits_zero_unlimited() {
371        let limits = ResourceLimits {
372            max_documents: 0,
373            max_file_size: 0,
374        };
375        let mut map = HashMap::new();
376        map.insert("rs".to_string(), "rust".to_string());
377
378        let mut tracker = DocumentTracker::new(limits, map);
379
380        // Should allow many documents when limit is 0
381        for i in 0..200 {
382            tracker
383                .open(
384                    PathBuf::from(format!("/test/file{i}.rs")),
385                    "content".to_string(),
386                )
387                .unwrap();
388        }
389        assert_eq!(tracker.len(), 200);
390
391        // Should allow large files when limit is 0
392        let huge_content = "x".repeat(100_000_000);
393        tracker
394            .open(PathBuf::from("/test/huge.rs"), huge_content)
395            .unwrap();
396    }
397
398    #[test]
399    fn test_document_state_clone() {
400        let state = DocumentState {
401            uri: "file:///test.rs".parse().unwrap(),
402            language_id: "rust".to_string(),
403            version: 5,
404            content: "fn main() {}".to_string(),
405        };
406
407        #[allow(clippy::redundant_clone)]
408        let cloned = state.clone();
409        assert_eq!(cloned.uri, state.uri);
410        assert_eq!(cloned.language_id, state.language_id);
411        assert_eq!(cloned.version, 5);
412        assert_eq!(cloned.content, state.content);
413    }
414
415    #[test]
416    fn test_update_nonexistent_document() {
417        let map = HashMap::new();
418        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
419        let path = PathBuf::from("/test/nonexistent.rs");
420
421        let version = tracker.update(&path, "new content".to_string());
422        assert_eq!(
423            version, None,
424            "Updating non-existent document should return None"
425        );
426    }
427
428    #[test]
429    fn test_close_nonexistent_document() {
430        let map = HashMap::new();
431        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
432        let path = PathBuf::from("/test/nonexistent.rs");
433
434        let state = tracker.close(&path);
435        assert_eq!(
436            state, None,
437            "Closing non-existent document should return None"
438        );
439    }
440
441    #[test]
442    fn test_close_all_documents() {
443        let mut map = HashMap::new();
444        map.insert("rs".to_string(), "rust".to_string());
445
446        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
447
448        tracker
449            .open(PathBuf::from("/test/file1.rs"), "content1".to_string())
450            .unwrap();
451        tracker
452            .open(PathBuf::from("/test/file2.rs"), "content2".to_string())
453            .unwrap();
454        tracker
455            .open(PathBuf::from("/test/file3.rs"), "content3".to_string())
456            .unwrap();
457
458        assert_eq!(tracker.len(), 3);
459
460        let closed = tracker.close_all();
461        assert_eq!(closed.len(), 3);
462        assert!(tracker.is_empty());
463    }
464
465    #[test]
466    fn test_get_nonexistent_document() {
467        let map = HashMap::new();
468        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
469        let path = PathBuf::from("/test/nonexistent.rs");
470
471        let state = tracker.get(&path);
472        assert!(
473            state.is_none(),
474            "Getting non-existent document should return None"
475        );
476    }
477
478    #[test]
479    fn test_document_version_increments() {
480        let mut map = HashMap::new();
481        map.insert("rs".to_string(), "rust".to_string());
482
483        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
484        let path = PathBuf::from("/test/versioned.rs");
485
486        tracker.open(path.clone(), "v1".to_string()).unwrap();
487        assert_eq!(tracker.get(&path).unwrap().version, 1);
488
489        tracker.update(&path, "v2".to_string());
490        assert_eq!(tracker.get(&path).unwrap().version, 2);
491
492        tracker.update(&path, "v3".to_string());
493        assert_eq!(tracker.get(&path).unwrap().version, 3);
494
495        tracker.update(&path, "v4".to_string());
496        assert_eq!(tracker.get(&path).unwrap().version, 4);
497    }
498
499    #[test]
500    #[allow(clippy::too_many_lines)]
501    fn test_detect_language_all_extensions() {
502        let mut map = HashMap::new();
503        map.insert("rs".to_string(), "rust".to_string());
504        map.insert("py".to_string(), "python".to_string());
505        map.insert("pyw".to_string(), "python".to_string());
506        map.insert("pyi".to_string(), "python".to_string());
507        map.insert("js".to_string(), "javascript".to_string());
508        map.insert("mjs".to_string(), "javascript".to_string());
509        map.insert("cjs".to_string(), "javascript".to_string());
510        map.insert("ts".to_string(), "typescript".to_string());
511        map.insert("mts".to_string(), "typescript".to_string());
512        map.insert("cts".to_string(), "typescript".to_string());
513        map.insert("tsx".to_string(), "typescriptreact".to_string());
514        map.insert("jsx".to_string(), "javascriptreact".to_string());
515        map.insert("go".to_string(), "go".to_string());
516        map.insert("c".to_string(), "c".to_string());
517        map.insert("h".to_string(), "c".to_string());
518        map.insert("cpp".to_string(), "cpp".to_string());
519        map.insert("cc".to_string(), "cpp".to_string());
520        map.insert("cxx".to_string(), "cpp".to_string());
521        map.insert("hpp".to_string(), "cpp".to_string());
522        map.insert("hh".to_string(), "cpp".to_string());
523        map.insert("hxx".to_string(), "cpp".to_string());
524        map.insert("java".to_string(), "java".to_string());
525        map.insert("rb".to_string(), "ruby".to_string());
526        map.insert("php".to_string(), "php".to_string());
527        map.insert("swift".to_string(), "swift".to_string());
528        map.insert("kt".to_string(), "kotlin".to_string());
529        map.insert("kts".to_string(), "kotlin".to_string());
530        map.insert("scala".to_string(), "scala".to_string());
531        map.insert("sc".to_string(), "scala".to_string());
532        map.insert("zig".to_string(), "zig".to_string());
533        map.insert("lua".to_string(), "lua".to_string());
534        map.insert("sh".to_string(), "shellscript".to_string());
535        map.insert("bash".to_string(), "shellscript".to_string());
536        map.insert("zsh".to_string(), "shellscript".to_string());
537        map.insert("json".to_string(), "json".to_string());
538        map.insert("toml".to_string(), "toml".to_string());
539        map.insert("yaml".to_string(), "yaml".to_string());
540        map.insert("yml".to_string(), "yaml".to_string());
541        map.insert("xml".to_string(), "xml".to_string());
542        map.insert("html".to_string(), "html".to_string());
543        map.insert("htm".to_string(), "html".to_string());
544        map.insert("css".to_string(), "css".to_string());
545        map.insert("scss".to_string(), "scss".to_string());
546        map.insert("less".to_string(), "less".to_string());
547        map.insert("md".to_string(), "markdown".to_string());
548        map.insert("markdown".to_string(), "markdown".to_string());
549
550        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
551        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
552        assert_eq!(detect_language(Path::new("script.pyw"), &map), "python");
553        assert_eq!(detect_language(Path::new("script.pyi"), &map), "python");
554        assert_eq!(detect_language(Path::new("app.js"), &map), "javascript");
555        assert_eq!(detect_language(Path::new("app.mjs"), &map), "javascript");
556        assert_eq!(detect_language(Path::new("app.cjs"), &map), "javascript");
557        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
558        assert_eq!(detect_language(Path::new("app.mts"), &map), "typescript");
559        assert_eq!(detect_language(Path::new("app.cts"), &map), "typescript");
560        assert_eq!(
561            detect_language(Path::new("component.tsx"), &map),
562            "typescriptreact"
563        );
564        assert_eq!(
565            detect_language(Path::new("component.jsx"), &map),
566            "javascriptreact"
567        );
568        assert_eq!(detect_language(Path::new("main.go"), &map), "go");
569        assert_eq!(detect_language(Path::new("main.c"), &map), "c");
570        assert_eq!(detect_language(Path::new("header.h"), &map), "c");
571        assert_eq!(detect_language(Path::new("main.cpp"), &map), "cpp");
572        assert_eq!(detect_language(Path::new("main.cc"), &map), "cpp");
573        assert_eq!(detect_language(Path::new("main.cxx"), &map), "cpp");
574        assert_eq!(detect_language(Path::new("header.hpp"), &map), "cpp");
575        assert_eq!(detect_language(Path::new("header.hh"), &map), "cpp");
576        assert_eq!(detect_language(Path::new("header.hxx"), &map), "cpp");
577        assert_eq!(detect_language(Path::new("Main.java"), &map), "java");
578        assert_eq!(detect_language(Path::new("script.rb"), &map), "ruby");
579        assert_eq!(detect_language(Path::new("index.php"), &map), "php");
580        assert_eq!(detect_language(Path::new("App.swift"), &map), "swift");
581        assert_eq!(detect_language(Path::new("Main.kt"), &map), "kotlin");
582        assert_eq!(detect_language(Path::new("script.kts"), &map), "kotlin");
583        assert_eq!(detect_language(Path::new("Main.scala"), &map), "scala");
584        assert_eq!(detect_language(Path::new("script.sc"), &map), "scala");
585        assert_eq!(detect_language(Path::new("main.zig"), &map), "zig");
586        assert_eq!(detect_language(Path::new("script.lua"), &map), "lua");
587        assert_eq!(detect_language(Path::new("script.sh"), &map), "shellscript");
588        assert_eq!(
589            detect_language(Path::new("script.bash"), &map),
590            "shellscript"
591        );
592        assert_eq!(
593            detect_language(Path::new("script.zsh"), &map),
594            "shellscript"
595        );
596        assert_eq!(detect_language(Path::new("data.json"), &map), "json");
597        assert_eq!(detect_language(Path::new("config.toml"), &map), "toml");
598        assert_eq!(detect_language(Path::new("config.yaml"), &map), "yaml");
599        assert_eq!(detect_language(Path::new("config.yml"), &map), "yaml");
600        assert_eq!(detect_language(Path::new("data.xml"), &map), "xml");
601        assert_eq!(detect_language(Path::new("index.html"), &map), "html");
602        assert_eq!(detect_language(Path::new("index.htm"), &map), "html");
603        assert_eq!(detect_language(Path::new("styles.css"), &map), "css");
604        assert_eq!(detect_language(Path::new("styles.scss"), &map), "scss");
605        assert_eq!(detect_language(Path::new("styles.less"), &map), "less");
606        assert_eq!(detect_language(Path::new("README.md"), &map), "markdown");
607        assert_eq!(
608            detect_language(Path::new("README.markdown"), &map),
609            "markdown"
610        );
611        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
612        assert_eq!(
613            detect_language(Path::new("no_extension"), &map),
614            "plaintext"
615        );
616    }
617
618    #[test]
619    fn test_path_to_uri_unix() {
620        #[cfg(not(windows))]
621        {
622            let path = Path::new("/home/user/project/main.rs");
623            let uri = path_to_uri(path);
624            assert!(
625                uri.as_str()
626                    .starts_with("file:///home/user/project/main.rs")
627            );
628        }
629    }
630
631    #[test]
632    fn test_path_to_uri_with_special_chars() {
633        let path = Path::new("/home/user/project-test/main.rs");
634        let uri = path_to_uri(path);
635        assert!(uri.as_str().starts_with("file://"));
636        assert!(uri.as_str().contains("project-test"));
637    }
638
639    #[test]
640    fn test_document_tracker_concurrent_operations() {
641        let mut map = HashMap::new();
642        map.insert("rs".to_string(), "rust".to_string());
643
644        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
645        let path1 = PathBuf::from("/test/file1.rs");
646        let path2 = PathBuf::from("/test/file2.rs");
647
648        tracker.open(path1.clone(), "content1".to_string()).unwrap();
649        tracker.open(path2.clone(), "content2".to_string()).unwrap();
650
651        assert_eq!(tracker.len(), 2);
652        assert!(tracker.is_open(&path1));
653        assert!(tracker.is_open(&path2));
654
655        tracker.update(&path1, "new content1".to_string());
656        assert_eq!(tracker.get(&path1).unwrap().content, "new content1");
657        assert_eq!(tracker.get(&path2).unwrap().content, "content2");
658
659        tracker.close(&path1);
660        assert_eq!(tracker.len(), 1);
661        assert!(!tracker.is_open(&path1));
662        assert!(tracker.is_open(&path2));
663    }
664
665    #[test]
666    fn test_empty_content() {
667        let mut map = HashMap::new();
668        map.insert("rs".to_string(), "rust".to_string());
669
670        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
671        let path = PathBuf::from("/test/empty.rs");
672
673        tracker.open(path.clone(), String::new()).unwrap();
674        assert!(tracker.is_open(&path));
675        assert_eq!(tracker.get(&path).unwrap().content, "");
676    }
677
678    #[test]
679    fn test_unicode_content() {
680        let mut map = HashMap::new();
681        map.insert("rs".to_string(), "rust".to_string());
682
683        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
684        let path = PathBuf::from("/test/unicode.rs");
685        let content = "fn テスト() { println!(\"こんにちは\"); }";
686
687        tracker.open(path.clone(), content.to_string()).unwrap();
688        assert_eq!(tracker.get(&path).unwrap().content, content);
689    }
690
691    #[test]
692    fn test_document_limit_exact_boundary() {
693        let limits = ResourceLimits {
694            max_documents: 5,
695            max_file_size: 1000,
696        };
697        let mut map = HashMap::new();
698        map.insert("rs".to_string(), "rust".to_string());
699
700        let mut tracker = DocumentTracker::new(limits, map);
701
702        for i in 0..5 {
703            tracker
704                .open(
705                    PathBuf::from(format!("/test/file{i}.rs")),
706                    "content".to_string(),
707                )
708                .unwrap();
709        }
710
711        assert_eq!(tracker.len(), 5);
712
713        let result = tracker.open(PathBuf::from("/test/file6.rs"), "content".to_string());
714        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
715    }
716
717    #[test]
718    fn test_file_size_exact_boundary() {
719        let limits = ResourceLimits {
720            max_documents: 10,
721            max_file_size: 100,
722        };
723        let mut map = HashMap::new();
724        map.insert("rs".to_string(), "rust".to_string());
725
726        let mut tracker = DocumentTracker::new(limits, map);
727
728        let exact_size_content = "x".repeat(100);
729        tracker
730            .open(PathBuf::from("/test/exact.rs"), exact_size_content)
731            .unwrap();
732
733        let over_size_content = "x".repeat(101);
734        let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content);
735        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
736    }
737
738    #[test]
739    fn test_detect_language_with_custom_extension() {
740        let mut map = HashMap::new();
741        map.insert("nu".to_string(), "nushell".to_string());
742
743        assert_eq!(detect_language(Path::new("script.nu"), &map), "nushell");
744
745        let empty_map = HashMap::new();
746        assert_eq!(
747            detect_language(Path::new("script.nu"), &empty_map),
748            "plaintext"
749        );
750    }
751
752    #[test]
753    fn test_detect_language_custom_overrides_default() {
754        let mut custom_map = HashMap::new();
755        custom_map.insert("rs".to_string(), "custom-rust".to_string());
756
757        assert_eq!(
758            detect_language(Path::new("main.rs"), &custom_map),
759            "custom-rust"
760        );
761
762        let mut default_map = HashMap::new();
763        default_map.insert("rs".to_string(), "rust".to_string());
764
765        assert_eq!(detect_language(Path::new("main.rs"), &default_map), "rust");
766    }
767
768    #[test]
769    fn test_detect_language_fallback_to_plaintext() {
770        let mut map = HashMap::new();
771        map.insert("nu".to_string(), "nushell".to_string());
772
773        // .rs not in custom map, should return plaintext
774        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
775    }
776
777    #[test]
778    fn test_detect_language_empty_map() {
779        let map = HashMap::new();
780        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
781    }
782
783    #[test]
784    fn test_document_tracker_with_extensions() {
785        let mut map = HashMap::new();
786        map.insert("nu".to_string(), "nushell".to_string());
787
788        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
789
790        let path = PathBuf::from("/test/script.nu");
791        tracker
792            .open(path.clone(), "# nushell script".to_string())
793            .unwrap();
794
795        let state = tracker.get(&path).unwrap();
796        assert_eq!(state.language_id, "nushell");
797    }
798
799    #[test]
800    fn test_document_tracker_uses_provided_map() {
801        let mut map = HashMap::new();
802        map.insert("rs".to_string(), "rust".to_string());
803
804        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
805        let path = PathBuf::from("/test/main.rs");
806        tracker
807            .open(path.clone(), "fn main() {}".to_string())
808            .unwrap();
809
810        let state = tracker.get(&path).unwrap();
811        assert_eq!(state.language_id, "rust");
812    }
813
814    #[test]
815    fn test_multiple_extensions_same_language() {
816        let mut map = HashMap::new();
817        map.insert("cpp".to_string(), "c++".to_string());
818        map.insert("cc".to_string(), "c++".to_string());
819        map.insert("cxx".to_string(), "c++".to_string());
820
821        assert_eq!(detect_language(Path::new("main.cpp"), &map), "c++");
822        assert_eq!(detect_language(Path::new("main.cc"), &map), "c++");
823        assert_eq!(detect_language(Path::new("main.cxx"), &map), "c++");
824    }
825
826    #[test]
827    fn test_case_sensitive_extensions() {
828        let mut map = HashMap::new();
829        map.insert("NU".to_string(), "nushell".to_string());
830
831        // Lowercase .nu should not match uppercase "NU" in map
832        assert_eq!(detect_language(Path::new("script.nu"), &map), "plaintext");
833    }
834
835    // ------------------------------------------------------------------
836    // uri_to_path
837    // ------------------------------------------------------------------
838
839    #[cfg(unix)]
840    #[test]
841    fn test_uri_to_path_file_scheme() {
842        let uri: Uri = "file:///home/user/main.rs".parse().unwrap();
843        let path = uri_to_path(&uri).unwrap();
844        assert_eq!(path, PathBuf::from("/home/user/main.rs"));
845    }
846
847    #[test]
848    fn test_uri_to_path_non_file_scheme_returns_none() {
849        let uri: Uri = "https://example.com/file.rs".parse().unwrap();
850        assert!(uri_to_path(&uri).is_none());
851    }
852
853    #[test]
854    fn test_uri_to_path_lsp_diagnostics_scheme_returns_none() {
855        // Custom scheme must not be decoded by uri_to_path.
856        let uri: Uri = "lsp-diagnostics:///home/user/main.rs".parse().unwrap();
857        assert!(uri_to_path(&uri).is_none());
858    }
859
860    #[test]
861    fn test_uri_to_path_with_authority_returns_none() {
862        // Authority-bearing file URIs must be rejected (UNC path defence).
863        // lsp_types::Uri may or may not accept this string; either way
864        // uri_to_path should return None.
865        let result = "file://server/share/path.rs"
866            .parse::<Uri>()
867            .ok()
868            .and_then(|u| uri_to_path(&u));
869        assert!(result.is_none());
870    }
871
872    // ------------------------------------------------------------------
873    // open_paths
874    // ------------------------------------------------------------------
875
876    #[test]
877    fn test_open_paths_empty_tracker() {
878        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
879        assert_eq!(tracker.open_paths().count(), 0);
880    }
881
882    #[test]
883    fn test_open_paths_populated_tracker() {
884        let mut map = HashMap::new();
885        map.insert("rs".to_string(), "rust".to_string());
886        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
887        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
888        tracker.open(PathBuf::from("/b.rs"), String::new()).unwrap();
889        let mut paths: Vec<_> = tracker.open_paths().collect();
890        paths.sort();
891        assert_eq!(paths, [Path::new("/a.rs"), Path::new("/b.rs")]);
892    }
893
894    #[test]
895    fn test_open_paths_after_close() {
896        let mut map = HashMap::new();
897        map.insert("rs".to_string(), "rust".to_string());
898        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
899        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
900        tracker.close(Path::new("/a.rs"));
901        assert_eq!(tracker.open_paths().count(), 0);
902    }
903}