Skip to main content

matrixcode_core/lsp/
client.rs

1//! LSP Client
2//!
3//! High-level LSP client that manages a single LSP server connection
4//! and provides APIs for hover, definition, references, and diagnostics.
5
6use anyhow::{Result, anyhow};
7use lsp_types::{
8    ClientCapabilities, Diagnostic, DiagnosticSeverity, GotoDefinitionParams,
9    Hover, HoverContents, HoverParams, InitializeParams,
10    InitializedParams, Location, MarkupKind, Position, ReferenceContext,
11    ReferenceParams, TextDocumentClientCapabilities, TextDocumentIdentifier,
12    TextDocumentItem, TextDocumentPositionParams, Url, WorkspaceClientCapabilities,
13    WorkspaceFolder,
14};
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18use tokio::sync::Mutex;
19
20use super::transport::LspTransport;
21use super::types::LspServerConfig;
22
23/// Result from hover request
24#[derive(Debug, Clone)]
25pub struct HoverResult {
26    /// Type signature or primary hover content
27    pub signature: String,
28    /// Documentation or additional info
29    pub documentation: Option<String>,
30}
31
32impl HoverResult {
33    /// Create a new hover result
34    pub fn new(signature: impl Into<String>) -> Self {
35        Self {
36            signature: signature.into(),
37            documentation: None,
38        }
39    }
40
41    /// Add documentation
42    pub fn with_documentation(mut self, doc: impl Into<String>) -> Self {
43        self.documentation = Some(doc.into());
44        self
45    }
46}
47
48/// LSP Client
49///
50/// Manages a single LSP server connection and provides high-level API
51/// for common LSP operations like hover, definition, references, and diagnostics.
52pub struct LspClient {
53    /// Transport layer for communication
54    transport: Arc<Mutex<Option<LspTransport>>>,
55    /// Language identifier (e.g., "rust", "typescript")
56    language: String,
57    /// Server name (e.g., "rust-analyzer")
58    server_name: String,
59    /// Project root path
60    project_root: PathBuf,
61    /// Cache of open files (URI -> content)
62    open_files: Arc<Mutex<HashMap<Url, String>>>,
63    /// Cache of diagnostics (URI -> diagnostics)
64    diagnostics_cache: Arc<Mutex<HashMap<Url, Vec<Diagnostic>>>>,
65    /// Server capabilities (set after initialization)
66    capabilities: Arc<Mutex<Option<lsp_types::ServerCapabilities>>>,
67}
68
69impl LspClient {
70    /// Create a new LSP client
71    pub fn new(language: impl Into<String>, server_name: impl Into<String>, project_root: PathBuf) -> Self {
72        Self {
73            transport: Arc::new(Mutex::new(None)),
74            language: language.into(),
75            server_name: server_name.into(),
76            project_root,
77            open_files: Arc::new(Mutex::new(HashMap::new())),
78            diagnostics_cache: Arc::new(Mutex::new(HashMap::new())),
79            capabilities: Arc::new(Mutex::new(None)),
80        }
81    }
82
83    /// Create LSP client from config
84    pub fn from_config(config: &LspServerConfig, project_root: PathBuf) -> Self {
85        Self::new(config.language.clone(), config.command.clone(), project_root)
86    }
87
88    /// Spawn and initialize the LSP server
89    pub async fn spawn(&self, config: &LspServerConfig) -> Result<()> {
90        // Start the server process
91        let transport = LspTransport::spawn(&config.command, &config.command, &config.args).await?;
92
93        {
94            let mut transport_guard = self.transport.lock().await;
95            *transport_guard = Some(transport);
96        }
97
98        // Initialize the server
99        self.initialize().await?;
100
101        log::info!("LSP client '{}' spawned and initialized successfully", self.server_name);
102        Ok(())
103    }
104
105    /// Send LSP initialize request
106    pub async fn initialize(&self) -> Result<()> {
107        let transport = self.transport.lock().await;
108        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
109
110        let root_uri = Url::from_file_path(&self.project_root)
111            .map_err(|_| anyhow!("Invalid project root path: {:?}", self.project_root))?;
112
113        let params = InitializeParams {
114            process_id: Some(std::process::id()),
115            root_path: None,
116            root_uri: Some(root_uri.clone()),
117            initialization_options: None,
118            capabilities: ClientCapabilities {
119                text_document: Some(TextDocumentClientCapabilities {
120                    hover: Some(lsp_types::HoverClientCapabilities {
121                        dynamic_registration: Some(false),
122                        content_format: Some(vec![MarkupKind::Markdown, MarkupKind::PlainText]),
123                    }),
124                    definition: Some(lsp_types::GotoCapability {
125                        dynamic_registration: Some(false),
126                        link_support: Some(true),
127                    }),
128                    references: Some(lsp_types::ReferenceClientCapabilities {
129                        dynamic_registration: Some(false),
130                    }),
131                    publish_diagnostics: Some(lsp_types::PublishDiagnosticsClientCapabilities {
132                        related_information: Some(true),
133                        tag_support: Some(lsp_types::TagSupport {
134                            value_set: vec![lsp_types::DiagnosticTag::UNNECESSARY, lsp_types::DiagnosticTag::DEPRECATED],
135                        }),
136                        version_support: Some(false),
137                        code_description_support: Some(true),
138                        data_support: Some(false),
139                    }),
140                    ..Default::default()
141                }),
142                workspace: Some(WorkspaceClientCapabilities {
143                    workspace_folders: Some(true),
144                    ..Default::default()
145                }),
146                ..Default::default()
147            },
148            trace: Some(lsp_types::TraceValue::Off),
149            workspace_folders: Some(vec![WorkspaceFolder {
150                uri: root_uri,
151                name: self.project_root
152                    .file_name()
153                    .map(|n| n.to_string_lossy().to_string())
154                    .unwrap_or_else(|| "workspace".to_string()),
155            }]),
156            client_info: Some(lsp_types::ClientInfo {
157                name: "matrixcode".to_string(),
158                version: Some(env!("CARGO_PKG_VERSION").to_string()),
159            }),
160            locale: None,
161            work_done_progress_params: Default::default(),
162        };
163
164        let result = transport
165            .send_request("initialize", serde_json::to_value(params)?)
166            .await?;
167
168        // Store capabilities
169        if let Some(capabilities) = result.get("capabilities") {
170            let caps: lsp_types::ServerCapabilities = serde_json::from_value(capabilities.clone())?;
171            let mut caps_guard = self.capabilities.lock().await;
172            *caps_guard = Some(caps);
173        }
174
175        // Send initialized notification
176        self.initialized().await?;
177
178        Ok(())
179    }
180
181    /// Send initialized notification
182    pub async fn initialized(&self) -> Result<()> {
183        let transport = self.transport.lock().await;
184        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
185
186        let params = InitializedParams {};
187        transport
188            .send_notification("initialized", serde_json::to_value(params)?)
189            .await?;
190
191        Ok(())
192    }
193
194    /// Open a file in the LSP server
195    pub async fn open_file(&self, uri: &Url, content: &str) -> Result<()> {
196        let transport = self.transport.lock().await;
197        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
198
199        let language_id = self.language.clone();
200        let version = 1;
201
202        let text_document = TextDocumentItem {
203            uri: uri.clone(),
204            language_id,
205            version,
206            text: content.to_string(),
207        };
208
209        let params = lsp_types::DidOpenTextDocumentParams { text_document };
210        transport
211            .send_notification("textDocument/didOpen", serde_json::to_value(params)?)
212            .await?;
213
214        // Cache the open file
215        let mut open_files = self.open_files.lock().await;
216        open_files.insert(uri.clone(), content.to_string());
217
218        log::debug!("Opened file in LSP: {}", uri);
219        Ok(())
220    }
221
222    /// Get hover information at a position
223    pub async fn hover(&self, uri: &Url, position: Position) -> Result<Option<HoverResult>> {
224        let transport = self.transport.lock().await;
225        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
226
227        let text_document = TextDocumentIdentifier { uri: uri.clone() };
228        let params = HoverParams {
229            text_document_position_params: TextDocumentPositionParams {
230                text_document,
231                position,
232            },
233            work_done_progress_params: Default::default(),
234        };
235
236        let result = transport
237            .send_request("textDocument/hover", serde_json::to_value(params)?)
238            .await?;
239
240        if result.is_null() {
241            return Ok(None);
242        }
243
244        let hover: Hover = serde_json::from_value(result)?;
245        Ok(Some(Self::parse_hover(hover)))
246    }
247
248    /// Parse hover response into HoverResult
249    fn parse_hover(hover: Hover) -> HoverResult {
250        let (signature, documentation) = match hover.contents {
251            HoverContents::Scalar(scalar) => {
252                let content = match scalar {
253                    lsp_types::MarkedString::String(s) => s,
254                    lsp_types::MarkedString::LanguageString(ls) => {
255                        format!("```{}\n{}\n```", ls.language, ls.value)
256                    }
257                };
258                (content, None)
259            }
260            HoverContents::Array(arr) => {
261                let parts: Vec<String> = arr
262                    .into_iter()
263                    .map(|ms| match ms {
264                        lsp_types::MarkedString::String(s) => s,
265                        lsp_types::MarkedString::LanguageString(ls) => {
266                            format!("```{}\n{}\n```", ls.language, ls.value)
267                        }
268                    })
269                    .collect();
270                let signature = parts.first().cloned().unwrap_or_default();
271                let documentation = if parts.len() > 1 {
272                    Some(parts[1..].join("\n\n"))
273                } else {
274                    None
275                };
276                (signature, documentation)
277            }
278            HoverContents::Markup(markup) => {
279                let content = markup.value;
280                // Try to split signature and documentation
281                if content.contains("\n\n") {
282                    let parts: Vec<&str> = content.splitn(2, "\n\n").collect();
283                    (parts[0].to_string(), Some(parts[1].to_string()))
284                } else {
285                    (content, None)
286                }
287            }
288        };
289
290        HoverResult { signature, documentation }
291    }
292
293    /// Get definition locations at a position
294    pub async fn definition(&self, uri: &Url, position: Position) -> Result<Vec<Location>> {
295        let transport = self.transport.lock().await;
296        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
297
298        let text_document = TextDocumentIdentifier { uri: uri.clone() };
299        let params = GotoDefinitionParams {
300            text_document_position_params: TextDocumentPositionParams {
301                text_document,
302                position,
303            },
304            work_done_progress_params: Default::default(),
305            partial_result_params: Default::default(),
306        };
307
308        let result = transport
309            .send_request("textDocument/definition", serde_json::to_value(params)?)
310            .await?;
311
312        if result.is_null() {
313            return Ok(Vec::new());
314        }
315
316        let locations = Self::parse_definition_response(result)?;
317        Ok(locations)
318    }
319
320    /// Parse definition response
321    fn parse_definition_response(result: serde_json::Value) -> Result<Vec<Location>> {
322        // Try to parse as LocationLink array first
323        if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(result.clone()) {
324            return Ok(links
325                .into_iter()
326                .map(|link| Location {
327                    uri: link.target_uri,
328                    range: link.target_selection_range,
329                })
330                .collect());
331        }
332
333        // Try to parse as Location array
334        if let Ok(locations) = serde_json::from_value::<Vec<Location>>(result.clone()) {
335            return Ok(locations);
336        }
337
338        // Try to parse as single Location
339        if let Ok(location) = serde_json::from_value::<Location>(result.clone()) {
340            return Ok(vec![location]);
341        }
342
343        // Try to parse as single LocationLink
344        if let Ok(link) = serde_json::from_value::<lsp_types::LocationLink>(result) {
345            return Ok(vec![Location {
346                uri: link.target_uri,
347                range: link.target_selection_range,
348            }]);
349        }
350
351        Ok(Vec::new())
352    }
353
354    /// Get reference locations at a position
355    pub async fn references(&self, uri: &Url, position: Position, include_declaration: bool) -> Result<Vec<Location>> {
356        let transport = self.transport.lock().await;
357        let transport = transport.as_ref().ok_or_else(|| anyhow!("Transport not initialized"))?;
358
359        let text_document = TextDocumentIdentifier { uri: uri.clone() };
360        let params = ReferenceParams {
361            text_document_position: TextDocumentPositionParams {
362                text_document,
363                position,
364            },
365            work_done_progress_params: Default::default(),
366            partial_result_params: Default::default(),
367            context: ReferenceContext {
368                include_declaration,
369            },
370        };
371
372        let result = transport
373            .send_request("textDocument/references", serde_json::to_value(params)?)
374            .await?;
375
376        if result.is_null() {
377            return Ok(Vec::new());
378        }
379
380        let locations: Vec<Location> = serde_json::from_value(result)?;
381        Ok(locations)
382    }
383
384    /// Get diagnostics for a file from cache
385    pub async fn diagnostics(&self, uri: &Url) -> Result<Vec<Diagnostic>> {
386        let cache = self.diagnostics_cache.lock().await;
387        Ok(cache.get(uri).cloned().unwrap_or_default())
388    }
389
390    /// Update diagnostics cache (called when receiving publishDiagnostics notification)
391    pub async fn update_diagnostics(&self, uri: Url, diagnostics: Vec<Diagnostic>) {
392        let mut cache = self.diagnostics_cache.lock().await;
393        cache.insert(uri, diagnostics);
394    }
395
396    /// Shutdown the LSP server gracefully
397    pub async fn shutdown(&self) -> Result<()> {
398        let mut transport_guard = self.transport.lock().await;
399
400        if let Some(transport) = transport_guard.take() {
401            // Send shutdown request
402            transport
403                .send_request("shutdown", serde_json::Value::Null)
404                .await?;
405
406            // Send exit notification
407            transport
408                .send_notification("exit", serde_json::Value::Null)
409                .await?;
410
411            // Close the transport
412            transport.close().await?;
413
414            log::info!("LSP client '{}' shutdown successfully", self.server_name);
415        }
416
417        // Clear caches
418        let mut open_files = self.open_files.lock().await;
419        open_files.clear();
420        let mut diagnostics_cache = self.diagnostics_cache.lock().await;
421        diagnostics_cache.clear();
422
423        Ok(())
424    }
425
426    /// Check if the client is connected
427    pub async fn is_connected(&self) -> bool {
428        let transport = self.transport.lock().await;
429        transport.is_some()
430    }
431
432    /// Get the language identifier
433    pub fn language(&self) -> &str {
434        &self.language
435    }
436
437    /// Get the server name
438    pub fn server_name(&self) -> &str {
439        &self.server_name
440    }
441
442    /// Get the project root
443    pub fn project_root(&self) -> &PathBuf {
444        &self.project_root
445    }
446
447    /// Get server capabilities
448    pub async fn capabilities(&self) -> Option<lsp_types::ServerCapabilities> {
449        let caps = self.capabilities.lock().await;
450        caps.clone()
451    }
452}
453
454// ============================================================================
455// Helper Functions
456// ============================================================================
457
458/// Format a location for human-readable output
459pub fn format_location(location: &Location) -> String {
460    let path = location.uri.to_file_path();
461    let path_str = path
462        .map(|p| p.to_string_lossy().to_string())
463        .unwrap_or_else(|_| location.uri.to_string());
464
465    let range = &location.range;
466    let start = &range.start;
467    format!(
468        "{}:{}:{}",
469        path_str,
470        start.line + 1,  // LSP uses 0-based line numbers
471        start.character + 1
472    )
473}
474
475/// Format a diagnostic for human-readable output
476pub fn format_diagnostic(diagnostic: &Diagnostic) -> String {
477    let severity = diagnostic.severity
478        .map(|s| format_severity(s))
479        .unwrap_or_else(|| "error".to_string());
480
481    let message = &diagnostic.message;
482
483    let location = diagnostic.related_information
484        .as_ref()
485        .and_then(|info| info.first())
486        .map(|info| format!(" at {}:{}", info.location.uri, info.location.range.start.line + 1))
487        .unwrap_or_default();
488
489    let code = diagnostic.code
490        .as_ref()
491        .map(|c| format!("[{}] ", match c {
492            lsp_types::NumberOrString::Number(n) => n.to_string(),
493            lsp_types::NumberOrString::String(s) => s.clone(),
494        }))
495        .unwrap_or_default();
496
497    format!("{}{}: {}{}", severity, code, message, location)
498}
499
500/// Format diagnostic severity
501fn format_severity(severity: DiagnosticSeverity) -> String {
502    match severity {
503        DiagnosticSeverity::ERROR => "error".to_string(),
504        DiagnosticSeverity::WARNING => "warning".to_string(),
505        DiagnosticSeverity::INFORMATION => "info".to_string(),
506        DiagnosticSeverity::HINT => "hint".to_string(),
507        _ => "unknown".to_string(),
508    }
509}
510
511/// Format hover result for display
512pub fn format_hover_result(hover: &HoverResult) -> String {
513    if let Some(doc) = &hover.documentation {
514        format!("{}\n\n{}", hover.signature, doc)
515    } else {
516        hover.signature.clone()
517    }
518}
519
520/// Create a file URL from a path
521pub fn path_to_uri(path: &PathBuf) -> Result<Url> {
522    Url::from_file_path(path)
523        .map_err(|_| anyhow!("Invalid file path: {:?}", path))
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_hover_result_new() {
532        let result = HoverResult::new("fn foo() -> i32");
533        assert_eq!(result.signature, "fn foo() -> i32");
534        assert!(result.documentation.is_none());
535    }
536
537    #[test]
538    fn test_hover_result_with_documentation() {
539        let result = HoverResult::new("fn foo() -> i32")
540            .with_documentation("This is a test function");
541        assert_eq!(result.signature, "fn foo() -> i32");
542        assert_eq!(result.documentation, Some("This is a test function".to_string()));
543    }
544
545    #[test]
546    fn test_format_hover_result() {
547        let hover = HoverResult::new("fn foo() -> i32")
548            .with_documentation("Docs");
549        let formatted = format_hover_result(&hover);
550        assert!(formatted.contains("fn foo() -> i32"));
551        assert!(formatted.contains("Docs"));
552    }
553
554    #[test]
555    fn test_format_severity() {
556        assert_eq!(format_severity(DiagnosticSeverity::ERROR), "error");
557        assert_eq!(format_severity(DiagnosticSeverity::WARNING), "warning");
558        assert_eq!(format_severity(DiagnosticSeverity::INFORMATION), "info");
559        assert_eq!(format_severity(DiagnosticSeverity::HINT), "hint");
560    }
561
562    #[test]
563    fn test_path_to_uri() {
564        let path = PathBuf::from("/tmp/test.rs");
565        let uri = path_to_uri(&path).unwrap();
566        assert!(uri.to_string().ends_with("test.rs"));
567    }
568}