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, PartialEq, Eq)]
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 extension_map: HashMap<String, String>,
53}
54
55impl DocumentTracker {
56 #[must_use]
58 pub fn new(limits: ResourceLimits, extension_map: HashMap<String, String>) -> Self {
59 Self {
60 documents: HashMap::new(),
61 limits,
62 extension_map,
63 }
64 }
65
66 #[must_use]
68 pub fn is_open(&self, path: &Path) -> bool {
69 self.documents.contains_key(path)
70 }
71
72 #[must_use]
74 pub fn get(&self, path: &Path) -> Option<&DocumentState> {
75 self.documents.get(path)
76 }
77
78 #[must_use]
80 pub fn len(&self) -> usize {
81 self.documents.len()
82 }
83
84 #[must_use]
86 pub fn is_empty(&self) -> bool {
87 self.documents.is_empty()
88 }
89
90 pub fn open(&mut self, path: PathBuf, content: String) -> Result<Uri> {
100 if self.limits.max_documents > 0 && self.documents.len() >= self.limits.max_documents {
102 return Err(Error::DocumentLimitExceeded {
103 current: self.documents.len(),
104 max: self.limits.max_documents,
105 });
106 }
107
108 let size = content.len() as u64;
110 if self.limits.max_file_size > 0 && size > self.limits.max_file_size {
111 return Err(Error::FileSizeLimitExceeded {
112 size,
113 max: self.limits.max_file_size,
114 });
115 }
116
117 let uri = path_to_uri(&path);
118 let language_id = detect_language(&path, &self.extension_map);
119
120 let state = DocumentState {
121 uri: uri.clone(),
122 language_id,
123 version: 1,
124 content,
125 };
126
127 self.documents.insert(path, state);
128 Ok(uri)
129 }
130
131 pub fn update(&mut self, path: &Path, content: String) -> Option<i32> {
135 if let Some(state) = self.documents.get_mut(path) {
136 state.version += 1;
137 state.content = content;
138 Some(state.version)
139 } else {
140 None
141 }
142 }
143
144 pub fn close(&mut self, path: &Path) -> Option<DocumentState> {
148 self.documents.remove(path)
149 }
150
151 pub fn close_all(&mut self) -> Vec<DocumentState> {
153 self.documents.drain().map(|(_, state)| state).collect()
154 }
155
156 pub async fn ensure_open(&mut self, path: &Path, lsp_client: &LspClient) -> Result<Uri> {
169 if let Some(state) = self.documents.get(path) {
170 return Ok(state.uri.clone());
171 }
172
173 let content = tokio::fs::read_to_string(path)
174 .await
175 .map_err(|e| Error::FileIo {
176 path: path.to_path_buf(),
177 source: e,
178 })?;
179
180 let uri = self.open(path.to_path_buf(), content.clone())?;
181 let state = self
182 .documents
183 .get(path)
184 .ok_or_else(|| Error::DocumentNotFound(path.to_path_buf()))?;
185
186 let params = DidOpenTextDocumentParams {
187 text_document: TextDocumentItem {
188 uri: uri.clone(),
189 language_id: state.language_id.clone(),
190 version: state.version,
191 text: content,
192 },
193 };
194
195 lsp_client.notify("textDocument/didOpen", params).await?;
196
197 Ok(uri)
198 }
199}
200
201#[must_use]
203pub fn path_to_uri(path: &Path) -> Uri {
204 let uri_string = if cfg!(windows) {
206 format!("file:///{}", path.display().to_string().replace('\\', "/"))
207 } else {
208 format!("file://{}", path.display())
209 };
210 #[allow(clippy::expect_used)]
212 uri_string.parse().expect("failed to create URI from path")
213}
214
215#[must_use]
220pub fn detect_language(path: &Path, extension_map: &HashMap<String, String>) -> String {
221 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
222
223 extension_map
224 .get(extension)
225 .cloned()
226 .unwrap_or_else(|| "plaintext".to_string())
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn test_detect_language() {
236 let mut map = HashMap::new();
237 map.insert("rs".to_string(), "rust".to_string());
238 map.insert("py".to_string(), "python".to_string());
239 map.insert("ts".to_string(), "typescript".to_string());
240
241 assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
242 assert_eq!(detect_language(Path::new("script.py"), &map), "python");
243 assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
244 assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
245 }
246
247 #[test]
248 fn test_document_tracker() {
249 let mut map = HashMap::new();
250 map.insert("rs".to_string(), "rust".to_string());
251
252 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
253 let path = PathBuf::from("/test/file.rs");
254
255 assert!(!tracker.is_open(&path));
256
257 tracker
258 .open(path.clone(), "fn main() {}".to_string())
259 .unwrap();
260 assert!(tracker.is_open(&path));
261 assert_eq!(tracker.len(), 1);
262
263 let state = tracker.get(&path).unwrap();
264 assert_eq!(state.version, 1);
265 assert_eq!(state.language_id, "rust");
266
267 let new_version = tracker.update(&path, "fn main() { println!() }".to_string());
268 assert_eq!(new_version, Some(2));
269
270 tracker.close(&path);
271 assert!(!tracker.is_open(&path));
272 assert!(tracker.is_empty());
273 }
274
275 #[test]
276 fn test_document_limit() {
277 let limits = ResourceLimits {
278 max_documents: 2,
279 max_file_size: 100,
280 };
281 let mut map = HashMap::new();
282 map.insert("rs".to_string(), "rust".to_string());
283
284 let mut tracker = DocumentTracker::new(limits, map);
285
286 tracker
288 .open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
289 .unwrap();
290 tracker
291 .open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
292 .unwrap();
293
294 let result = tracker.open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string());
296 assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
297 }
298
299 #[test]
300 fn test_file_size_limit() {
301 let limits = ResourceLimits {
302 max_documents: 10,
303 max_file_size: 10,
304 };
305 let mut map = HashMap::new();
306 map.insert("rs".to_string(), "rust".to_string());
307
308 let mut tracker = DocumentTracker::new(limits, map);
309
310 tracker
312 .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
313 .unwrap();
314
315 let large_content = "x".repeat(100);
317 let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
318 assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
319 }
320
321 #[test]
322 fn test_resource_limits_default() {
323 let limits = ResourceLimits::default();
324 assert_eq!(limits.max_documents, 100);
325 assert_eq!(limits.max_file_size, 10 * 1024 * 1024);
326 }
327
328 #[test]
329 fn test_resource_limits_custom() {
330 let limits = ResourceLimits {
331 max_documents: 50,
332 max_file_size: 5 * 1024 * 1024,
333 };
334 assert_eq!(limits.max_documents, 50);
335 assert_eq!(limits.max_file_size, 5 * 1024 * 1024);
336 }
337
338 #[test]
339 fn test_resource_limits_zero_unlimited() {
340 let limits = ResourceLimits {
341 max_documents: 0,
342 max_file_size: 0,
343 };
344 let mut map = HashMap::new();
345 map.insert("rs".to_string(), "rust".to_string());
346
347 let mut tracker = DocumentTracker::new(limits, map);
348
349 for i in 0..200 {
351 tracker
352 .open(
353 PathBuf::from(format!("/test/file{i}.rs")),
354 "content".to_string(),
355 )
356 .unwrap();
357 }
358 assert_eq!(tracker.len(), 200);
359
360 let huge_content = "x".repeat(100_000_000);
362 tracker
363 .open(PathBuf::from("/test/huge.rs"), huge_content)
364 .unwrap();
365 }
366
367 #[test]
368 fn test_document_state_clone() {
369 let state = DocumentState {
370 uri: "file:///test.rs".parse().unwrap(),
371 language_id: "rust".to_string(),
372 version: 5,
373 content: "fn main() {}".to_string(),
374 };
375
376 #[allow(clippy::redundant_clone)]
377 let cloned = state.clone();
378 assert_eq!(cloned.uri, state.uri);
379 assert_eq!(cloned.language_id, state.language_id);
380 assert_eq!(cloned.version, 5);
381 assert_eq!(cloned.content, state.content);
382 }
383
384 #[test]
385 fn test_update_nonexistent_document() {
386 let map = HashMap::new();
387 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
388 let path = PathBuf::from("/test/nonexistent.rs");
389
390 let version = tracker.update(&path, "new content".to_string());
391 assert_eq!(
392 version, None,
393 "Updating non-existent document should return None"
394 );
395 }
396
397 #[test]
398 fn test_close_nonexistent_document() {
399 let map = HashMap::new();
400 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
401 let path = PathBuf::from("/test/nonexistent.rs");
402
403 let state = tracker.close(&path);
404 assert_eq!(
405 state, None,
406 "Closing non-existent document should return None"
407 );
408 }
409
410 #[test]
411 fn test_close_all_documents() {
412 let mut map = HashMap::new();
413 map.insert("rs".to_string(), "rust".to_string());
414
415 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
416
417 tracker
418 .open(PathBuf::from("/test/file1.rs"), "content1".to_string())
419 .unwrap();
420 tracker
421 .open(PathBuf::from("/test/file2.rs"), "content2".to_string())
422 .unwrap();
423 tracker
424 .open(PathBuf::from("/test/file3.rs"), "content3".to_string())
425 .unwrap();
426
427 assert_eq!(tracker.len(), 3);
428
429 let closed = tracker.close_all();
430 assert_eq!(closed.len(), 3);
431 assert!(tracker.is_empty());
432 }
433
434 #[test]
435 fn test_get_nonexistent_document() {
436 let map = HashMap::new();
437 let tracker = DocumentTracker::new(ResourceLimits::default(), map);
438 let path = PathBuf::from("/test/nonexistent.rs");
439
440 let state = tracker.get(&path);
441 assert!(
442 state.is_none(),
443 "Getting non-existent document should return None"
444 );
445 }
446
447 #[test]
448 fn test_document_version_increments() {
449 let mut map = HashMap::new();
450 map.insert("rs".to_string(), "rust".to_string());
451
452 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
453 let path = PathBuf::from("/test/versioned.rs");
454
455 tracker.open(path.clone(), "v1".to_string()).unwrap();
456 assert_eq!(tracker.get(&path).unwrap().version, 1);
457
458 tracker.update(&path, "v2".to_string());
459 assert_eq!(tracker.get(&path).unwrap().version, 2);
460
461 tracker.update(&path, "v3".to_string());
462 assert_eq!(tracker.get(&path).unwrap().version, 3);
463
464 tracker.update(&path, "v4".to_string());
465 assert_eq!(tracker.get(&path).unwrap().version, 4);
466 }
467
468 #[test]
469 #[allow(clippy::too_many_lines)]
470 fn test_detect_language_all_extensions() {
471 let mut map = HashMap::new();
472 map.insert("rs".to_string(), "rust".to_string());
473 map.insert("py".to_string(), "python".to_string());
474 map.insert("pyw".to_string(), "python".to_string());
475 map.insert("pyi".to_string(), "python".to_string());
476 map.insert("js".to_string(), "javascript".to_string());
477 map.insert("mjs".to_string(), "javascript".to_string());
478 map.insert("cjs".to_string(), "javascript".to_string());
479 map.insert("ts".to_string(), "typescript".to_string());
480 map.insert("mts".to_string(), "typescript".to_string());
481 map.insert("cts".to_string(), "typescript".to_string());
482 map.insert("tsx".to_string(), "typescriptreact".to_string());
483 map.insert("jsx".to_string(), "javascriptreact".to_string());
484 map.insert("go".to_string(), "go".to_string());
485 map.insert("c".to_string(), "c".to_string());
486 map.insert("h".to_string(), "c".to_string());
487 map.insert("cpp".to_string(), "cpp".to_string());
488 map.insert("cc".to_string(), "cpp".to_string());
489 map.insert("cxx".to_string(), "cpp".to_string());
490 map.insert("hpp".to_string(), "cpp".to_string());
491 map.insert("hh".to_string(), "cpp".to_string());
492 map.insert("hxx".to_string(), "cpp".to_string());
493 map.insert("java".to_string(), "java".to_string());
494 map.insert("rb".to_string(), "ruby".to_string());
495 map.insert("php".to_string(), "php".to_string());
496 map.insert("swift".to_string(), "swift".to_string());
497 map.insert("kt".to_string(), "kotlin".to_string());
498 map.insert("kts".to_string(), "kotlin".to_string());
499 map.insert("scala".to_string(), "scala".to_string());
500 map.insert("sc".to_string(), "scala".to_string());
501 map.insert("zig".to_string(), "zig".to_string());
502 map.insert("lua".to_string(), "lua".to_string());
503 map.insert("sh".to_string(), "shellscript".to_string());
504 map.insert("bash".to_string(), "shellscript".to_string());
505 map.insert("zsh".to_string(), "shellscript".to_string());
506 map.insert("json".to_string(), "json".to_string());
507 map.insert("toml".to_string(), "toml".to_string());
508 map.insert("yaml".to_string(), "yaml".to_string());
509 map.insert("yml".to_string(), "yaml".to_string());
510 map.insert("xml".to_string(), "xml".to_string());
511 map.insert("html".to_string(), "html".to_string());
512 map.insert("htm".to_string(), "html".to_string());
513 map.insert("css".to_string(), "css".to_string());
514 map.insert("scss".to_string(), "scss".to_string());
515 map.insert("less".to_string(), "less".to_string());
516 map.insert("md".to_string(), "markdown".to_string());
517 map.insert("markdown".to_string(), "markdown".to_string());
518
519 assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
520 assert_eq!(detect_language(Path::new("script.py"), &map), "python");
521 assert_eq!(detect_language(Path::new("script.pyw"), &map), "python");
522 assert_eq!(detect_language(Path::new("script.pyi"), &map), "python");
523 assert_eq!(detect_language(Path::new("app.js"), &map), "javascript");
524 assert_eq!(detect_language(Path::new("app.mjs"), &map), "javascript");
525 assert_eq!(detect_language(Path::new("app.cjs"), &map), "javascript");
526 assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
527 assert_eq!(detect_language(Path::new("app.mts"), &map), "typescript");
528 assert_eq!(detect_language(Path::new("app.cts"), &map), "typescript");
529 assert_eq!(
530 detect_language(Path::new("component.tsx"), &map),
531 "typescriptreact"
532 );
533 assert_eq!(
534 detect_language(Path::new("component.jsx"), &map),
535 "javascriptreact"
536 );
537 assert_eq!(detect_language(Path::new("main.go"), &map), "go");
538 assert_eq!(detect_language(Path::new("main.c"), &map), "c");
539 assert_eq!(detect_language(Path::new("header.h"), &map), "c");
540 assert_eq!(detect_language(Path::new("main.cpp"), &map), "cpp");
541 assert_eq!(detect_language(Path::new("main.cc"), &map), "cpp");
542 assert_eq!(detect_language(Path::new("main.cxx"), &map), "cpp");
543 assert_eq!(detect_language(Path::new("header.hpp"), &map), "cpp");
544 assert_eq!(detect_language(Path::new("header.hh"), &map), "cpp");
545 assert_eq!(detect_language(Path::new("header.hxx"), &map), "cpp");
546 assert_eq!(detect_language(Path::new("Main.java"), &map), "java");
547 assert_eq!(detect_language(Path::new("script.rb"), &map), "ruby");
548 assert_eq!(detect_language(Path::new("index.php"), &map), "php");
549 assert_eq!(detect_language(Path::new("App.swift"), &map), "swift");
550 assert_eq!(detect_language(Path::new("Main.kt"), &map), "kotlin");
551 assert_eq!(detect_language(Path::new("script.kts"), &map), "kotlin");
552 assert_eq!(detect_language(Path::new("Main.scala"), &map), "scala");
553 assert_eq!(detect_language(Path::new("script.sc"), &map), "scala");
554 assert_eq!(detect_language(Path::new("main.zig"), &map), "zig");
555 assert_eq!(detect_language(Path::new("script.lua"), &map), "lua");
556 assert_eq!(detect_language(Path::new("script.sh"), &map), "shellscript");
557 assert_eq!(
558 detect_language(Path::new("script.bash"), &map),
559 "shellscript"
560 );
561 assert_eq!(
562 detect_language(Path::new("script.zsh"), &map),
563 "shellscript"
564 );
565 assert_eq!(detect_language(Path::new("data.json"), &map), "json");
566 assert_eq!(detect_language(Path::new("config.toml"), &map), "toml");
567 assert_eq!(detect_language(Path::new("config.yaml"), &map), "yaml");
568 assert_eq!(detect_language(Path::new("config.yml"), &map), "yaml");
569 assert_eq!(detect_language(Path::new("data.xml"), &map), "xml");
570 assert_eq!(detect_language(Path::new("index.html"), &map), "html");
571 assert_eq!(detect_language(Path::new("index.htm"), &map), "html");
572 assert_eq!(detect_language(Path::new("styles.css"), &map), "css");
573 assert_eq!(detect_language(Path::new("styles.scss"), &map), "scss");
574 assert_eq!(detect_language(Path::new("styles.less"), &map), "less");
575 assert_eq!(detect_language(Path::new("README.md"), &map), "markdown");
576 assert_eq!(
577 detect_language(Path::new("README.markdown"), &map),
578 "markdown"
579 );
580 assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
581 assert_eq!(
582 detect_language(Path::new("no_extension"), &map),
583 "plaintext"
584 );
585 }
586
587 #[test]
588 fn test_path_to_uri_unix() {
589 #[cfg(not(windows))]
590 {
591 let path = Path::new("/home/user/project/main.rs");
592 let uri = path_to_uri(path);
593 assert!(
594 uri.as_str()
595 .starts_with("file:///home/user/project/main.rs")
596 );
597 }
598 }
599
600 #[test]
601 fn test_path_to_uri_with_special_chars() {
602 let path = Path::new("/home/user/project-test/main.rs");
603 let uri = path_to_uri(path);
604 assert!(uri.as_str().starts_with("file://"));
605 assert!(uri.as_str().contains("project-test"));
606 }
607
608 #[test]
609 fn test_document_tracker_concurrent_operations() {
610 let mut map = HashMap::new();
611 map.insert("rs".to_string(), "rust".to_string());
612
613 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
614 let path1 = PathBuf::from("/test/file1.rs");
615 let path2 = PathBuf::from("/test/file2.rs");
616
617 tracker.open(path1.clone(), "content1".to_string()).unwrap();
618 tracker.open(path2.clone(), "content2".to_string()).unwrap();
619
620 assert_eq!(tracker.len(), 2);
621 assert!(tracker.is_open(&path1));
622 assert!(tracker.is_open(&path2));
623
624 tracker.update(&path1, "new content1".to_string());
625 assert_eq!(tracker.get(&path1).unwrap().content, "new content1");
626 assert_eq!(tracker.get(&path2).unwrap().content, "content2");
627
628 tracker.close(&path1);
629 assert_eq!(tracker.len(), 1);
630 assert!(!tracker.is_open(&path1));
631 assert!(tracker.is_open(&path2));
632 }
633
634 #[test]
635 fn test_empty_content() {
636 let mut map = HashMap::new();
637 map.insert("rs".to_string(), "rust".to_string());
638
639 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
640 let path = PathBuf::from("/test/empty.rs");
641
642 tracker.open(path.clone(), String::new()).unwrap();
643 assert!(tracker.is_open(&path));
644 assert_eq!(tracker.get(&path).unwrap().content, "");
645 }
646
647 #[test]
648 fn test_unicode_content() {
649 let mut map = HashMap::new();
650 map.insert("rs".to_string(), "rust".to_string());
651
652 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
653 let path = PathBuf::from("/test/unicode.rs");
654 let content = "fn テスト() { println!(\"こんにちは\"); }";
655
656 tracker.open(path.clone(), content.to_string()).unwrap();
657 assert_eq!(tracker.get(&path).unwrap().content, content);
658 }
659
660 #[test]
661 fn test_document_limit_exact_boundary() {
662 let limits = ResourceLimits {
663 max_documents: 5,
664 max_file_size: 1000,
665 };
666 let mut map = HashMap::new();
667 map.insert("rs".to_string(), "rust".to_string());
668
669 let mut tracker = DocumentTracker::new(limits, map);
670
671 for i in 0..5 {
672 tracker
673 .open(
674 PathBuf::from(format!("/test/file{i}.rs")),
675 "content".to_string(),
676 )
677 .unwrap();
678 }
679
680 assert_eq!(tracker.len(), 5);
681
682 let result = tracker.open(PathBuf::from("/test/file6.rs"), "content".to_string());
683 assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
684 }
685
686 #[test]
687 fn test_file_size_exact_boundary() {
688 let limits = ResourceLimits {
689 max_documents: 10,
690 max_file_size: 100,
691 };
692 let mut map = HashMap::new();
693 map.insert("rs".to_string(), "rust".to_string());
694
695 let mut tracker = DocumentTracker::new(limits, map);
696
697 let exact_size_content = "x".repeat(100);
698 tracker
699 .open(PathBuf::from("/test/exact.rs"), exact_size_content)
700 .unwrap();
701
702 let over_size_content = "x".repeat(101);
703 let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content);
704 assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
705 }
706
707 #[test]
708 fn test_detect_language_with_custom_extension() {
709 let mut map = HashMap::new();
710 map.insert("nu".to_string(), "nushell".to_string());
711
712 assert_eq!(detect_language(Path::new("script.nu"), &map), "nushell");
713
714 let empty_map = HashMap::new();
715 assert_eq!(
716 detect_language(Path::new("script.nu"), &empty_map),
717 "plaintext"
718 );
719 }
720
721 #[test]
722 fn test_detect_language_custom_overrides_default() {
723 let mut custom_map = HashMap::new();
724 custom_map.insert("rs".to_string(), "custom-rust".to_string());
725
726 assert_eq!(
727 detect_language(Path::new("main.rs"), &custom_map),
728 "custom-rust"
729 );
730
731 let mut default_map = HashMap::new();
732 default_map.insert("rs".to_string(), "rust".to_string());
733
734 assert_eq!(detect_language(Path::new("main.rs"), &default_map), "rust");
735 }
736
737 #[test]
738 fn test_detect_language_fallback_to_plaintext() {
739 let mut map = HashMap::new();
740 map.insert("nu".to_string(), "nushell".to_string());
741
742 assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
744 }
745
746 #[test]
747 fn test_detect_language_empty_map() {
748 let map = HashMap::new();
749 assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
750 }
751
752 #[test]
753 fn test_document_tracker_with_extensions() {
754 let mut map = HashMap::new();
755 map.insert("nu".to_string(), "nushell".to_string());
756
757 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
758
759 let path = PathBuf::from("/test/script.nu");
760 tracker
761 .open(path.clone(), "# nushell script".to_string())
762 .unwrap();
763
764 let state = tracker.get(&path).unwrap();
765 assert_eq!(state.language_id, "nushell");
766 }
767
768 #[test]
769 fn test_document_tracker_uses_provided_map() {
770 let mut map = HashMap::new();
771 map.insert("rs".to_string(), "rust".to_string());
772
773 let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
774 let path = PathBuf::from("/test/main.rs");
775 tracker
776 .open(path.clone(), "fn main() {}".to_string())
777 .unwrap();
778
779 let state = tracker.get(&path).unwrap();
780 assert_eq!(state.language_id, "rust");
781 }
782
783 #[test]
784 fn test_multiple_extensions_same_language() {
785 let mut map = HashMap::new();
786 map.insert("cpp".to_string(), "c++".to_string());
787 map.insert("cc".to_string(), "c++".to_string());
788 map.insert("cxx".to_string(), "c++".to_string());
789
790 assert_eq!(detect_language(Path::new("main.cpp"), &map), "c++");
791 assert_eq!(detect_language(Path::new("main.cc"), &map), "c++");
792 assert_eq!(detect_language(Path::new("main.cxx"), &map), "c++");
793 }
794
795 #[test]
796 fn test_case_sensitive_extensions() {
797 let mut map = HashMap::new();
798 map.insert("NU".to_string(), "nushell".to_string());
799
800 assert_eq!(detect_language(Path::new("script.nu"), &map), "plaintext");
802 }
803}