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