server.rs

  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}