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