1use async_lsp::lsp_types::{
2 DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentDiagnosticParams,
3 DocumentSymbolParams, DocumentSymbolResponse, TextDocumentIdentifier, TextDocumentItem,
4 Url,
5};
6use async_lsp::{LanguageServer, ServerSocket as LSPServerSocket};
7use rmcp::ErrorData as MCPError;
8use std::path::Path;
9
10use crate::lsp::opened_documents::OpenedDocuments;
11
12const MAX_OPEN_DOCUMENTS: usize = 50;
13
14/// Language identifiers for LSP textDocument/didOpen.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum LanguageId {
17 Rust,
18 Python,
19 JavaScript,
20 TypeScript,
21 JavaScriptReact,
22 TypeScriptReact,
23 Go,
24 Java,
25 C,
26 Cpp,
27 CSharp,
28 Ruby,
29 Php,
30 Swift,
31 Kotlin,
32 Scala,
33 Html,
34 Css,
35 Scss,
36 Less,
37 Json,
38 Xml,
39 Yaml,
40 Toml,
41 Markdown,
42 Shell,
43 /// For languages not explicitly enumerated or unknown file types.
44 Other(String),
45}
46
47impl LanguageId {
48 /// Infer language ID from file path extension.
49 pub fn from_path(path: &Path) -> Self {
50 match path.extension().and_then(|e| e.to_str()) {
51 Some("rs") => Self::Rust,
52 Some("py") | Some("pyi") => Self::Python,
53 Some("js") | Some("mjs") | Some("cjs") => Self::JavaScript,
54 Some("ts") | Some("mts") | Some("cts") => Self::TypeScript,
55 Some("jsx") => Self::JavaScriptReact,
56 Some("tsx") => Self::TypeScriptReact,
57 Some("go") => Self::Go,
58 Some("java") => Self::Java,
59 Some("c") | Some("h") => Self::C,
60 Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") | Some("hxx") => Self::Cpp,
61 Some("cs") => Self::CSharp,
62 Some("rb") => Self::Ruby,
63 Some("php") => Self::Php,
64 Some("swift") => Self::Swift,
65 Some("kt") | Some("kts") => Self::Kotlin,
66 Some("scala") | Some("sc") => Self::Scala,
67 Some("html") | Some("htm") => Self::Html,
68 Some("css") => Self::Css,
69 Some("scss") => Self::Scss,
70 Some("less") => Self::Less,
71 Some("json") | Some("jsonc") => Self::Json,
72 Some("xml") => Self::Xml,
73 Some("yaml") | Some("yml") => Self::Yaml,
74 Some("toml") => Self::Toml,
75 Some("md") | Some("markdown") => Self::Markdown,
76 Some("sh") | Some("bash") | Some("zsh") => Self::Shell,
77 _ => Self::Other("plaintext".into()),
78 }
79 }
80
81 /// Convert to LSP-compliant language identifier string.
82 pub fn as_lsp_identifier(&self) -> &str {
83 match self {
84 Self::Rust => "rust",
85 Self::Python => "python",
86 Self::JavaScript => "javascript",
87 Self::TypeScript => "typescript",
88 Self::JavaScriptReact => "javascriptreact",
89 Self::TypeScriptReact => "typescriptreact",
90 Self::Go => "go",
91 Self::Java => "java",
92 Self::C => "c",
93 Self::Cpp => "cpp",
94 Self::CSharp => "csharp",
95 Self::Ruby => "ruby",
96 Self::Php => "php",
97 Self::Swift => "swift",
98 Self::Kotlin => "kotlin",
99 Self::Scala => "scala",
100 Self::Html => "html",
101 Self::Css => "css",
102 Self::Scss => "scss",
103 Self::Less => "less",
104 Self::Json => "json",
105 Self::Xml => "xml",
106 Self::Yaml => "yaml",
107 Self::Toml => "toml",
108 Self::Markdown => "markdown",
109 Self::Shell => "shellscript",
110 Self::Other(s) => s.as_str(),
111 }
112 }
113}
114
115/// Wrapper around LSPServerSocket that manages document lifecycle.
116#[derive(Clone)]
117pub struct LSPServer {
118 socket: LSPServerSocket,
119 opened_docs: OpenedDocuments,
120}
121
122impl LSPServer {
123 pub fn new(socket: LSPServerSocket) -> Self {
124 Self {
125 socket,
126 opened_docs: OpenedDocuments::new(),
127 }
128 }
129
130 /// Open a document with the LSP server. Path must be canonical.
131 /// If the document is already open, this is a no-op (but updates last_accessed).
132 pub async fn open_document(&self, path: impl AsRef<Path>, content: &str) -> Result<(), MCPError> {
133 let path = path.as_ref();
134
135 // Check if already open
136 if self.opened_docs.is_open(path)
137 .map_err(|e| MCPError::internal_error(e.to_string(), None))?
138 {
139 self.opened_docs.touch(path)
140 .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
141 return Ok(());
142 }
143
144 // Infer language from file extension
145 let language_id = LanguageId::from_path(path);
146
147 // Convert path to URI
148 let uri = Url::from_file_path(path)
149 .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
150
151 // Send didOpen notification
152 self.socket
153 .clone()
154 .did_open(DidOpenTextDocumentParams {
155 text_document: TextDocumentItem {
156 uri: uri.clone(),
157 language_id: language_id.as_lsp_identifier().to_string(),
158 version: 0,
159 text: content.to_string(),
160 },
161 })
162 .map_err(|e| MCPError::internal_error(format!("Failed to send didOpen: {}", e), None))?;
163
164 // Track the document
165 self.opened_docs
166 .insert(path.to_path_buf(), content, 0)
167 .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
168
169 // Evict old documents if needed
170 self.evict_old_documents().await?;
171
172 Ok(())
173 }
174
175 /// Close a document with the LSP server. Path must be canonical.
176 pub async fn close_document(&self, path: impl AsRef<Path>) -> Result<(), MCPError> {
177 let path = path.as_ref();
178
179 // Convert path to URI
180 let uri = Url::from_file_path(path)
181 .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
182
183 // Send didClose notification
184 self.socket
185 .clone()
186 .did_close(DidCloseTextDocumentParams {
187 text_document: TextDocumentIdentifier { uri },
188 })
189 .map_err(|e| MCPError::internal_error(format!("Failed to send didClose: {}", e), None))?;
190
191 // Remove from tracking
192 self.opened_docs
193 .remove(path)
194 .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
195
196 Ok(())
197 }
198
199 /// Evict least recently used documents to keep count under MAX_OPEN_DOCUMENTS.
200 async fn evict_old_documents(&self) -> Result<(), MCPError> {
201 let evicted = self.opened_docs.evict_lru(MAX_OPEN_DOCUMENTS);
202
203 for path in evicted {
204 // Close with LSP server
205 let uri = Url::from_file_path(&path)
206 .map_err(|_| MCPError::internal_error(format!("Invalid file path: {}", path.display()), None))?;
207
208 self.socket
209 .clone()
210 .did_close(DidCloseTextDocumentParams {
211 text_document: TextDocumentIdentifier { uri },
212 })
213 .map_err(|e| MCPError::internal_error(format!("Failed to send didClose during eviction: {}", e), None))?;
214 }
215
216 Ok(())
217 }
218
219 /// Get diagnostics for a document. Path must be canonical.
220 pub async fn document_diagnostic(
221 &self,
222 path: impl AsRef<Path>,
223 ) -> Result<async_lsp::lsp_types::DocumentDiagnosticReportResult, MCPError> {
224 let path = path.as_ref();
225
226 // Touch to update LRU
227 self.opened_docs
228 .touch(path)
229 .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
230
231 let uri = Url::from_file_path(path)
232 .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
233
234 self.socket
235 .clone()
236 .document_diagnostic(DocumentDiagnosticParams {
237 text_document: TextDocumentIdentifier { uri },
238 identifier: None,
239 previous_result_id: None,
240 work_done_progress_params: Default::default(),
241 partial_result_params: Default::default(),
242 })
243 .await
244 .map_err(|e| MCPError::internal_error(format!("Failed to get diagnostics: {}", e), None))
245 }
246
247 /// Get document symbols (outline). Path must be canonical.
248 pub async fn document_symbols(
249 &self,
250 path: impl AsRef<Path>,
251 ) -> Result<Option<DocumentSymbolResponse>, MCPError> {
252 let path = path.as_ref();
253
254 // Touch to update LRU
255 self.opened_docs
256 .touch(path)
257 .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
258
259 let uri = Url::from_file_path(path)
260 .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
261
262 self.socket
263 .clone()
264 .document_symbol(DocumentSymbolParams {
265 text_document: TextDocumentIdentifier { uri },
266 work_done_progress_params: Default::default(),
267 partial_result_params: Default::default(),
268 })
269 .await
270 .map_err(|e| MCPError::internal_error(format!("Failed to get document symbols: {}", e), None))
271 }
272}