Skip to main content

rustant_tools/lsp/
mod.rs

1//! LSP Client Integration for Rustant.
2//!
3//! Provides language server protocol (LSP) client capabilities, enabling the
4//! agent to leverage existing language servers for code intelligence such as
5//! hover information, go-to-definition, find references, diagnostics,
6//! completions, rename, and formatting.
7//!
8//! ## Architecture
9//!
10//! ```text
11//! LspTool ──> LspManager (LspBackend) ──> LspClient ──> Language Server Process
12//!                  │                           │
13//!                  └── ServerRegistry           └── Content-Length framing (JSON-RPC 2.0)
14//! ```
15
16pub mod client;
17pub mod discovery;
18pub mod tools;
19pub mod types;
20
21use async_trait::async_trait;
22use client::{LspClient, LspError};
23use discovery::ServerRegistry;
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use tokio::sync::Mutex;
28use tracing::{info, warn};
29use types::{CompletionItem, Diagnostic, Location, TextEdit, WorkspaceEdit};
30
31/// Trait abstracting LSP operations for testability.
32///
33/// The [`LspManager`] implements this trait using real language server processes.
34/// Tests can provide mock implementations to exercise the tools without
35/// requiring actual language servers.
36#[async_trait]
37pub trait LspBackend: Send + Sync {
38    /// Get hover information at a position.
39    async fn hover(
40        &self,
41        file: &Path,
42        line: u32,
43        character: u32,
44    ) -> Result<Option<String>, LspError>;
45
46    /// Go to definition of the symbol at a position.
47    async fn definition(
48        &self,
49        file: &Path,
50        line: u32,
51        character: u32,
52    ) -> Result<Vec<Location>, LspError>;
53
54    /// Find all references to the symbol at a position.
55    async fn references(
56        &self,
57        file: &Path,
58        line: u32,
59        character: u32,
60    ) -> Result<Vec<Location>, LspError>;
61
62    /// Get diagnostics for a file.
63    async fn diagnostics(&self, file: &Path) -> Result<Vec<Diagnostic>, LspError>;
64
65    /// Get completion suggestions at a position.
66    async fn completions(
67        &self,
68        file: &Path,
69        line: u32,
70        character: u32,
71    ) -> Result<Vec<CompletionItem>, LspError>;
72
73    /// Rename a symbol across the project.
74    async fn rename(
75        &self,
76        file: &Path,
77        line: u32,
78        character: u32,
79        new_name: &str,
80    ) -> Result<WorkspaceEdit, LspError>;
81
82    /// Format a file.
83    async fn format(&self, file: &Path) -> Result<Vec<TextEdit>, LspError>;
84}
85
86/// Manages language server processes and routes LSP requests.
87///
88/// The `LspManager` lazily starts language server processes on demand based
89/// on file extension. It maintains a cache of running clients and routes
90/// requests to the appropriate server.
91pub struct LspManager {
92    workspace: PathBuf,
93    registry: ServerRegistry,
94    clients: Mutex<HashMap<String, LspClient>>,
95}
96
97impl LspManager {
98    /// Create a new `LspManager` for the given workspace directory.
99    pub fn new(workspace: PathBuf) -> Self {
100        Self {
101            workspace,
102            registry: ServerRegistry::with_defaults(),
103            clients: Mutex::new(HashMap::new()),
104        }
105    }
106
107    /// Create an `LspManager` with a custom server registry.
108    pub fn with_registry(workspace: PathBuf, registry: ServerRegistry) -> Self {
109        Self {
110            workspace,
111            registry,
112            clients: Mutex::new(HashMap::new()),
113        }
114    }
115
116    /// Get or start the LSP client for the given file.
117    ///
118    /// Detects the language from the file extension, looks up the server
119    /// configuration, and starts the server if not already running.
120    async fn get_client_for_file(&self, file: &Path) -> Result<String, LspError> {
121        let language = discovery::ServerRegistry::detect_language(file).ok_or_else(|| {
122            let ext = file
123                .extension()
124                .and_then(|e| e.to_str())
125                .unwrap_or("unknown");
126            LspError::UnsupportedLanguage {
127                language: ext.to_string(),
128            }
129        })?;
130
131        let mut clients = self.clients.lock().await;
132
133        if clients.contains_key(&language) {
134            return Ok(language);
135        }
136
137        let config = self
138            .registry
139            .get(&language)
140            .ok_or_else(|| LspError::UnsupportedLanguage {
141                language: language.clone(),
142            })?;
143
144        info!(
145            language = %language,
146            command = %config.command,
147            "Starting language server"
148        );
149
150        match LspClient::start(&config.command, &config.args, &self.workspace).await {
151            Ok(client) => {
152                clients.insert(language.clone(), client);
153                Ok(language)
154            }
155            Err(e) => {
156                warn!(
157                    language = %language,
158                    error = %e,
159                    "Failed to start language server"
160                );
161                Err(e)
162            }
163        }
164    }
165
166    /// Extract hover text from the raw hover result.
167    fn extract_hover_text(hover: &types::HoverResult) -> String {
168        match &hover.contents {
169            types::HoverContents::Scalar(marked) => match marked {
170                types::MarkedString::String(s) => s.clone(),
171                types::MarkedString::LanguageString { language, value } => {
172                    format!("```{}\n{}\n```", language, value)
173                }
174            },
175            types::HoverContents::Markup(markup) => markup.value.clone(),
176            types::HoverContents::Array(items) => items
177                .iter()
178                .map(|m| match m {
179                    types::MarkedString::String(s) => s.clone(),
180                    types::MarkedString::LanguageString { language, value } => {
181                        format!("```{}\n{}\n```", language, value)
182                    }
183                })
184                .collect::<Vec<_>>()
185                .join("\n\n"),
186        }
187    }
188}
189
190#[async_trait]
191impl LspBackend for LspManager {
192    async fn hover(
193        &self,
194        file: &Path,
195        line: u32,
196        character: u32,
197    ) -> Result<Option<String>, LspError> {
198        let language = self.get_client_for_file(file).await?;
199        let mut clients = self.clients.lock().await;
200        let client = clients
201            .get_mut(&language)
202            .ok_or_else(|| LspError::ServerNotRunning {
203                language: language.clone(),
204            })?;
205
206        let result = client.hover(file, line, character).await?;
207        Ok(result.map(|h| Self::extract_hover_text(&h)))
208    }
209
210    async fn definition(
211        &self,
212        file: &Path,
213        line: u32,
214        character: u32,
215    ) -> Result<Vec<Location>, LspError> {
216        let language = self.get_client_for_file(file).await?;
217        let mut clients = self.clients.lock().await;
218        let client = clients
219            .get_mut(&language)
220            .ok_or_else(|| LspError::ServerNotRunning {
221                language: language.clone(),
222            })?;
223        client.definition(file, line, character).await
224    }
225
226    async fn references(
227        &self,
228        file: &Path,
229        line: u32,
230        character: u32,
231    ) -> Result<Vec<Location>, LspError> {
232        let language = self.get_client_for_file(file).await?;
233        let mut clients = self.clients.lock().await;
234        let client = clients
235            .get_mut(&language)
236            .ok_or_else(|| LspError::ServerNotRunning {
237                language: language.clone(),
238            })?;
239        client.references(file, line, character).await
240    }
241
242    async fn diagnostics(&self, file: &Path) -> Result<Vec<Diagnostic>, LspError> {
243        let language = self.get_client_for_file(file).await?;
244        let clients = self.clients.lock().await;
245        let client = clients
246            .get(&language)
247            .ok_or_else(|| LspError::ServerNotRunning {
248                language: language.clone(),
249            })?;
250        client.diagnostics(file)
251    }
252
253    async fn completions(
254        &self,
255        file: &Path,
256        line: u32,
257        character: u32,
258    ) -> Result<Vec<CompletionItem>, LspError> {
259        let language = self.get_client_for_file(file).await?;
260        let mut clients = self.clients.lock().await;
261        let client = clients
262            .get_mut(&language)
263            .ok_or_else(|| LspError::ServerNotRunning {
264                language: language.clone(),
265            })?;
266        client.completions(file, line, character).await
267    }
268
269    async fn rename(
270        &self,
271        file: &Path,
272        line: u32,
273        character: u32,
274        new_name: &str,
275    ) -> Result<WorkspaceEdit, LspError> {
276        let language = self.get_client_for_file(file).await?;
277        let mut clients = self.clients.lock().await;
278        let client = clients
279            .get_mut(&language)
280            .ok_or_else(|| LspError::ServerNotRunning {
281                language: language.clone(),
282            })?;
283        client.rename(file, line, character, new_name).await
284    }
285
286    async fn format(&self, file: &Path) -> Result<Vec<TextEdit>, LspError> {
287        let language = self.get_client_for_file(file).await?;
288        let mut clients = self.clients.lock().await;
289        let client = clients
290            .get_mut(&language)
291            .ok_or_else(|| LspError::ServerNotRunning {
292                language: language.clone(),
293            })?;
294        client.format(file).await
295    }
296}
297
298/// Create all LSP tools backed by the given `LspBackend`.
299///
300/// Returns a vector of tool instances ready for registration.
301pub fn create_lsp_tools(backend: Arc<dyn LspBackend>) -> Vec<Arc<dyn crate::registry::Tool>> {
302    vec![
303        Arc::new(tools::LspHoverTool::new(Arc::clone(&backend))),
304        Arc::new(tools::LspDefinitionTool::new(Arc::clone(&backend))),
305        Arc::new(tools::LspReferencesTool::new(Arc::clone(&backend))),
306        Arc::new(tools::LspDiagnosticsTool::new(Arc::clone(&backend))),
307        Arc::new(tools::LspCompletionsTool::new(Arc::clone(&backend))),
308        Arc::new(tools::LspRenameTool::new(Arc::clone(&backend))),
309        Arc::new(tools::LspFormatTool::new(Arc::clone(&backend))),
310    ]
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_lsp_manager_creation() {
319        let manager = LspManager::new(PathBuf::from("/tmp/workspace"));
320        assert_eq!(manager.workspace, PathBuf::from("/tmp/workspace"));
321    }
322
323    #[test]
324    fn test_extract_hover_text_scalar_string() {
325        let hover = types::HoverResult {
326            contents: types::HoverContents::Scalar(types::MarkedString::String(
327                "fn main()".to_string(),
328            )),
329            range: None,
330        };
331        assert_eq!(LspManager::extract_hover_text(&hover), "fn main()");
332    }
333
334    #[test]
335    fn test_extract_hover_text_scalar_language_string() {
336        let hover = types::HoverResult {
337            contents: types::HoverContents::Scalar(types::MarkedString::LanguageString {
338                language: "rust".to_string(),
339                value: "fn main()".to_string(),
340            }),
341            range: None,
342        };
343        assert_eq!(
344            LspManager::extract_hover_text(&hover),
345            "```rust\nfn main()\n```"
346        );
347    }
348
349    #[test]
350    fn test_extract_hover_text_markup() {
351        let hover = types::HoverResult {
352            contents: types::HoverContents::Markup(types::MarkupContent {
353                kind: "markdown".to_string(),
354                value: "# Hello\nWorld".to_string(),
355            }),
356            range: None,
357        };
358        assert_eq!(LspManager::extract_hover_text(&hover), "# Hello\nWorld");
359    }
360
361    #[test]
362    fn test_extract_hover_text_array() {
363        let hover = types::HoverResult {
364            contents: types::HoverContents::Array(vec![
365                types::MarkedString::String("Type: i32".to_string()),
366                types::MarkedString::LanguageString {
367                    language: "rust".to_string(),
368                    value: "let x: i32 = 42;".to_string(),
369                },
370            ]),
371            range: None,
372        };
373        let text = LspManager::extract_hover_text(&hover);
374        assert!(text.contains("Type: i32"));
375        assert!(text.contains("```rust\nlet x: i32 = 42;\n```"));
376    }
377
378    #[test]
379    fn test_create_lsp_tools_returns_seven() {
380        use crate::lsp::types::*;
381
382        struct DummyBackend;
383
384        #[async_trait]
385        impl LspBackend for DummyBackend {
386            async fn hover(&self, _: &Path, _: u32, _: u32) -> Result<Option<String>, LspError> {
387                Ok(None)
388            }
389            async fn definition(
390                &self,
391                _: &Path,
392                _: u32,
393                _: u32,
394            ) -> Result<Vec<Location>, LspError> {
395                Ok(vec![])
396            }
397            async fn references(
398                &self,
399                _: &Path,
400                _: u32,
401                _: u32,
402            ) -> Result<Vec<Location>, LspError> {
403                Ok(vec![])
404            }
405            async fn diagnostics(&self, _: &Path) -> Result<Vec<Diagnostic>, LspError> {
406                Ok(vec![])
407            }
408            async fn completions(
409                &self,
410                _: &Path,
411                _: u32,
412                _: u32,
413            ) -> Result<Vec<CompletionItem>, LspError> {
414                Ok(vec![])
415            }
416            async fn rename(
417                &self,
418                _: &Path,
419                _: u32,
420                _: u32,
421                _: &str,
422            ) -> Result<WorkspaceEdit, LspError> {
423                Ok(WorkspaceEdit { changes: None })
424            }
425            async fn format(&self, _: &Path) -> Result<Vec<TextEdit>, LspError> {
426                Ok(vec![])
427            }
428        }
429
430        let backend: Arc<dyn LspBackend> = Arc::new(DummyBackend);
431        let tools = create_lsp_tools(backend);
432        assert_eq!(tools.len(), 7);
433
434        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
435        assert!(names.contains(&"lsp_hover"));
436        assert!(names.contains(&"lsp_definition"));
437        assert!(names.contains(&"lsp_references"));
438        assert!(names.contains(&"lsp_diagnostics"));
439        assert!(names.contains(&"lsp_completions"));
440        assert!(names.contains(&"lsp_rename"));
441        assert!(names.contains(&"lsp_format"));
442    }
443
444    #[test]
445    fn test_lsp_manager_with_custom_registry() {
446        let registry = ServerRegistry::with_defaults();
447        let manager = LspManager::with_registry(PathBuf::from("/tmp"), registry);
448        assert_eq!(manager.workspace, PathBuf::from("/tmp"));
449    }
450}